mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
POC: Unified History (#94318)
This commit is contained in:
parent
f5d44ff51d
commit
e0af98dec8
@ -10,9 +10,10 @@ import { isShallowEqual } from 'app/core/utils/isShallowEqual';
|
||||
import { KioskMode } from 'app/types';
|
||||
|
||||
import { RouteDescriptor } from '../../navigation/types';
|
||||
import { buildBreadcrumbs } from '../Breadcrumbs/utils';
|
||||
|
||||
import { ReturnToPreviousProps } from './ReturnToPrevious/ReturnToPrevious';
|
||||
import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
||||
import { HistoryEntry, TOP_BAR_LEVEL_HEIGHT } from './types';
|
||||
|
||||
export interface AppChromeState {
|
||||
chromeless?: boolean;
|
||||
@ -31,6 +32,7 @@ export interface AppChromeState {
|
||||
|
||||
export const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked';
|
||||
export const DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY = 'grafana.navigation.open';
|
||||
export const HISTORY_LOCAL_STORAGE_KEY = 'grafana.navigation.history';
|
||||
|
||||
export class AppChromeService {
|
||||
searchBarStorageKey = 'SearchBar_Hidden';
|
||||
@ -99,6 +101,8 @@ export class AppChromeService {
|
||||
newState.chromeless = newState.kioskMode === KioskMode.Full || this.currentRoute?.chromeless;
|
||||
|
||||
if (!this.ignoreStateUpdate(newState, current)) {
|
||||
config.featureToggles.unifiedHistory &&
|
||||
store.setObject(HISTORY_LOCAL_STORAGE_KEY, this.getUpdatedHistory(newState));
|
||||
this.state.next(newState);
|
||||
}
|
||||
}
|
||||
@ -127,6 +131,29 @@ export class AppChromeService {
|
||||
window.sessionStorage.removeItem('returnToPrevious');
|
||||
};
|
||||
|
||||
private getUpdatedHistory(newState: AppChromeState): HistoryEntry[] {
|
||||
const breadcrumbs = buildBreadcrumbs(newState.sectionNav.node, newState.pageNav, { text: 'Home', url: '/' }, true);
|
||||
const newPageNav = newState.pageNav || newState.sectionNav.node;
|
||||
|
||||
let entries = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []);
|
||||
const clickedHistory = store.getObject<boolean>('CLICKING_HISTORY');
|
||||
if (clickedHistory) {
|
||||
store.setObject('CLICKING_HISTORY', false);
|
||||
return entries;
|
||||
}
|
||||
if (!newPageNav) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
let lastEntry = entries[0];
|
||||
if (!lastEntry || lastEntry.name !== newPageNav.text) {
|
||||
lastEntry = { name: newPageNav.text, views: [], breadcrumbs, time: Date.now(), url: window.location.href };
|
||||
}
|
||||
if (lastEntry !== entries[0]) {
|
||||
entries = [lastEntry, ...entries];
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
private ignoreStateUpdate(newState: AppChromeState, current: AppChromeState) {
|
||||
if (isShallowEqual(newState, current)) {
|
||||
return true;
|
||||
|
@ -0,0 +1,80 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, store } from '@grafana/data';
|
||||
import { Drawer, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { RecordHistoryEntryEvent } from 'app/types/events';
|
||||
|
||||
import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService';
|
||||
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
|
||||
import { HistoryEntry } from '../types';
|
||||
|
||||
import { HistoryWrapper } from './HistoryWrapper';
|
||||
|
||||
export function HistoryContainer() {
|
||||
const [showHistoryDrawer, onToggleShowHistoryDrawer] = useToggle(false);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = appEvents.subscribe(RecordHistoryEntryEvent, (ev) => {
|
||||
const clickedHistory = store.getObject<boolean>('CLICKING_HISTORY');
|
||||
if (clickedHistory) {
|
||||
store.setObject('CLICKING_HISTORY', false);
|
||||
return;
|
||||
}
|
||||
const history = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []);
|
||||
let lastEntry = history[0];
|
||||
const newUrl = ev.payload.url;
|
||||
const lastUrl = lastEntry.views[0]?.url;
|
||||
if (lastUrl !== newUrl) {
|
||||
lastEntry.views = [
|
||||
{
|
||||
name: ev.payload.name,
|
||||
description: ev.payload.description,
|
||||
url: newUrl,
|
||||
time: Date.now(),
|
||||
},
|
||||
...lastEntry.views,
|
||||
];
|
||||
store.setObject(HISTORY_LOCAL_STORAGE_KEY, [...history]);
|
||||
}
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolbarButton
|
||||
onClick={onToggleShowHistoryDrawer}
|
||||
iconOnly
|
||||
icon="history"
|
||||
aria-label={t('nav.history-container.drawer-tittle', 'History')}
|
||||
/>
|
||||
<NavToolbarSeparator className={styles.separator} />
|
||||
{showHistoryDrawer && (
|
||||
<Drawer
|
||||
title={t('nav.history-container.drawer-tittle', 'History')}
|
||||
onClose={onToggleShowHistoryDrawer}
|
||||
size="md"
|
||||
>
|
||||
<HistoryWrapper onClose={() => onToggleShowHistoryDrawer(false)} />
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
separator: css({
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
204
public/app/core/components/AppChrome/History/HistoryWrapper.tsx
Normal file
204
public/app/core/components/AppChrome/History/HistoryWrapper.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { css } from '@emotion/css';
|
||||
import moment from 'moment';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { FieldType, GrafanaTheme2, store } from '@grafana/data';
|
||||
import { Button, Card, IconButton, Space, Stack, Text, useStyles2, Box, Sparkline, useTheme2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService';
|
||||
import { HistoryEntry } from '../types';
|
||||
|
||||
export function HistoryWrapper({ onClose }: { onClose: () => void }) {
|
||||
const history = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []).filter((entry) => {
|
||||
return moment(entry.time).isAfter(moment().subtract(2, 'day').startOf('day'));
|
||||
});
|
||||
const [numItemsToShow, setNumItemsToShow] = useState(5);
|
||||
|
||||
const selectedTime = history.find((entry) => {
|
||||
return entry.url === window.location.href || entry.views.some((view) => view.url === window.location.href);
|
||||
})?.time;
|
||||
|
||||
const hist = history.slice(0, numItemsToShow).reduce((acc: { [key: string]: HistoryEntry[] }, entry) => {
|
||||
const date = moment(entry.time);
|
||||
let key = '';
|
||||
if (date.isSame(moment(), 'day')) {
|
||||
key = t('nav.history-wrapper.today', 'Today');
|
||||
} else if (date.isSame(moment().subtract(1, 'day'), 'day')) {
|
||||
key = t('nav.history-wrapper.yesterday', 'Yesterday');
|
||||
} else {
|
||||
key = date.format('YYYY-MM-DD');
|
||||
}
|
||||
acc[key] = [...(acc[key] || []), entry];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Stack direction="column" alignItems="flex-start">
|
||||
<Box width="100%">
|
||||
{Object.keys(hist).map((entries, date) => {
|
||||
return (
|
||||
<Stack key={date} direction="column" gap={1}>
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
{entries}
|
||||
</Text>
|
||||
{hist[entries].map((entry, index) => {
|
||||
return (
|
||||
<HistoryEntryAppView
|
||||
key={index}
|
||||
entry={entry}
|
||||
isSelected={entry.time === selectedTime}
|
||||
onClick={() => onClose()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{history.length > numItemsToShow && (
|
||||
<Button variant="secondary" fill="text" onClick={() => setNumItemsToShow(numItemsToShow + 5)}>
|
||||
{t('nav.history-wrapper.show-more', 'Show more')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
interface ItemProps {
|
||||
entry: HistoryEntry;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
const [isExpanded, setIsExpanded] = useState(isSelected && entry.views.length > 0);
|
||||
const { breadcrumbs, views, time, url, sparklineData } = entry;
|
||||
const expandedLabel = isExpanded
|
||||
? t('nav.history-wrapper.collapse', 'Collapse')
|
||||
: t('nav.history-wrapper.expand', 'Expand');
|
||||
|
||||
const selectedViewTime =
|
||||
isSelected &&
|
||||
entry.views.find((entry) => {
|
||||
return entry.url === window.location.href;
|
||||
})?.time;
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack>
|
||||
{views.length > 0 ? (
|
||||
<IconButton
|
||||
name={isExpanded ? 'angle-down' : 'angle-right'}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-label={expandedLabel}
|
||||
className={styles.iconButton}
|
||||
/>
|
||||
) : (
|
||||
<Space h={2} />
|
||||
)}
|
||||
|
||||
<Card
|
||||
onClick={() => {
|
||||
store.setObject('CLICKING_HISTORY', true);
|
||||
onClick();
|
||||
}}
|
||||
href={url}
|
||||
isCompact={true}
|
||||
className={isSelected ? undefined : styles.card}
|
||||
>
|
||||
<Stack direction="column">
|
||||
<div>
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<Text key={index}>
|
||||
{breadcrumb.text} {index !== breadcrumbs.length - 1 ? '> ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
<Text color="secondary">{moment(time).format('h:mm A')}</Text>
|
||||
{sparklineData && (
|
||||
<Sparkline
|
||||
theme={theme}
|
||||
width={240}
|
||||
height={40}
|
||||
config={{
|
||||
custom: {
|
||||
fillColor: 'rgba(130, 181, 216, 0.1)',
|
||||
lineColor: '#82B5D8',
|
||||
},
|
||||
}}
|
||||
sparkline={{
|
||||
y: {
|
||||
type: FieldType.number,
|
||||
name: 'test',
|
||||
config: {},
|
||||
values: sparklineData.values,
|
||||
state: {
|
||||
range: {
|
||||
...sparklineData.range,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
{isExpanded && (
|
||||
<div className={styles.expanded}>
|
||||
{views.map((view, index) => {
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
href={view.url}
|
||||
onClick={() => {
|
||||
store.setObject('CLICKING_HISTORY', true);
|
||||
onClick();
|
||||
}}
|
||||
isCompact={true}
|
||||
className={view.time === selectedViewTime ? undefined : styles.card}
|
||||
>
|
||||
<Stack direction="column" gap={0}>
|
||||
<Text variant="bodySmall">{view.name}</Text>
|
||||
{view.description && (
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
{view.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
card: css({
|
||||
background: 'none',
|
||||
}),
|
||||
iconButton: css({
|
||||
margin: 0,
|
||||
}),
|
||||
expanded: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginLeft: theme.spacing(5),
|
||||
gap: theme.spacing(1),
|
||||
position: 'relative',
|
||||
'&:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: theme.spacing(-2),
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
background: theme.colors.border.weak,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
@ -14,6 +14,7 @@ import { useSelector } from 'app/types';
|
||||
import { Branding } from '../../Branding/Branding';
|
||||
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
|
||||
import { buildBreadcrumbs } from '../../Breadcrumbs/utils';
|
||||
import { HistoryContainer } from '../History/HistoryContainer';
|
||||
import { enrichHelpItem } from '../MegaMenu/utils';
|
||||
import { NewsContainer } from '../News/NewsContainer';
|
||||
import { QuickAdd } from '../QuickAdd/QuickAdd';
|
||||
@ -49,6 +50,7 @@ export const SingleTopBar = memo(function SingleTopBar({
|
||||
const profileNode = navIndex['profile'];
|
||||
const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID];
|
||||
const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav);
|
||||
const unifiedHistoryEnabled = config.featureToggles.unifiedHistory;
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
@ -72,6 +74,7 @@ export const SingleTopBar = memo(function SingleTopBar({
|
||||
<Stack gap={0.5} alignItems="center">
|
||||
<TopSearchBarCommandPaletteTrigger />
|
||||
<QuickAdd />
|
||||
{unifiedHistoryEnabled && <HistoryContainer />}
|
||||
{enrichedHelpNode && (
|
||||
<Dropdown overlay={() => <TopNavBarMenu node={enrichedHelpNode} />} placement="bottom-end">
|
||||
<ToolbarButton iconOnly icon="question-circle" aria-label="Help" />
|
||||
|
@ -5,3 +5,28 @@ export interface ToolbarUpdateProps {
|
||||
pageNav?: NavModelItem;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface HistoryEntryView {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface HistoryEntrySparkline {
|
||||
values: number[];
|
||||
range: {
|
||||
min: number;
|
||||
max: number;
|
||||
delta: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
name: string;
|
||||
time: number;
|
||||
breadcrumbs: NavModelItem[];
|
||||
url: string;
|
||||
views: HistoryEntryView[];
|
||||
sparklineData?: HistoryEntrySparkline;
|
||||
}
|
||||
|
@ -2,7 +2,12 @@ import { NavModelItem } from '@grafana/data';
|
||||
|
||||
import { Breadcrumb } from './types';
|
||||
|
||||
export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelItem, homeNav?: NavModelItem) {
|
||||
export function buildBreadcrumbs(
|
||||
sectionNav: NavModelItem,
|
||||
pageNav?: NavModelItem,
|
||||
homeNav?: NavModelItem,
|
||||
skipHome?: boolean
|
||||
) {
|
||||
const crumbs: Breadcrumb[] = [];
|
||||
let foundHome = false;
|
||||
let lastPath: string | undefined = undefined;
|
||||
@ -22,7 +27,9 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
|
||||
|
||||
// Check if we found home/root if if so return early
|
||||
if (homeNav && urlToMatch === homeNav.url) {
|
||||
if (!skipHome) {
|
||||
crumbs.unshift({ text: homeNav.text, href: node.url ?? '' });
|
||||
}
|
||||
foundHome = true;
|
||||
return;
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { getTimeZoneInfo, GrafanaTheme2, InternalTimeZones, TIME_FORMAT } from '@grafana/data';
|
||||
import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil';
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
SceneComponentProps,
|
||||
SceneObjectBase,
|
||||
@ -15,6 +16,8 @@ import {
|
||||
SceneVariableValueChangedEvent,
|
||||
} from '@grafana/scenes';
|
||||
import { Stack, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { RecordHistoryEntryEvent } from 'app/types/events';
|
||||
|
||||
import { DataTrail, DataTrailState, getTopSceneFor } from './DataTrail';
|
||||
import { SerializedTrailHistory } from './TrailStore/TrailStore';
|
||||
@ -148,17 +151,26 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addTrailStep(
|
||||
trail,
|
||||
'time',
|
||||
parseTimeTooltip({
|
||||
const tooltip = parseTimeTooltip({
|
||||
from: newState.from,
|
||||
to: newState.to,
|
||||
timeZone: newState.timeZone,
|
||||
});
|
||||
|
||||
this.addTrailStep(trail, 'time', tooltip);
|
||||
|
||||
if (config.featureToggles.unifiedHistory) {
|
||||
appEvents.publish(
|
||||
new RecordHistoryEntryEvent({
|
||||
name: 'Time range changed',
|
||||
description: tooltip,
|
||||
url: window.location.href,
|
||||
time: Date.now(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { AnnotationQuery, BusEventBase, BusEventWithPayload, eventFactory } from '@grafana/data';
|
||||
import { IconName, ButtonVariant } from '@grafana/ui';
|
||||
import { HistoryEntryView } from 'app/core/components/AppChrome/types';
|
||||
|
||||
/**
|
||||
* Event Payloads
|
||||
@ -209,3 +210,7 @@ export class PanelEditEnteredEvent extends BusEventWithPayload<number> {
|
||||
export class PanelEditExitedEvent extends BusEventWithPayload<number> {
|
||||
static type = 'panel-edit-finished';
|
||||
}
|
||||
|
||||
export class RecordHistoryEntryEvent extends BusEventWithPayload<HistoryEntryView> {
|
||||
static type = 'record-history-entry';
|
||||
}
|
||||
|
@ -2176,6 +2176,16 @@
|
||||
"help/documentation": "Documentation",
|
||||
"help/keyboard-shortcuts": "Keyboard shortcuts",
|
||||
"help/support": "Support",
|
||||
"history-container": {
|
||||
"drawer-tittle": "History"
|
||||
},
|
||||
"history-wrapper": {
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"show-more": "Show more",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
},
|
||||
"home": {
|
||||
"title": "Home"
|
||||
},
|
||||
|
@ -2176,6 +2176,16 @@
|
||||
"help/documentation": "Đőčūmęʼnŧäŧįőʼn",
|
||||
"help/keyboard-shortcuts": "Ķęyþőäřđ şĥőřŧčūŧş",
|
||||
"help/support": "Ŝūppőřŧ",
|
||||
"history-container": {
|
||||
"drawer-tittle": "Ħįşŧőřy"
|
||||
},
|
||||
"history-wrapper": {
|
||||
"collapse": "Cőľľäpşę",
|
||||
"expand": "Ēχpäʼnđ",
|
||||
"show-more": "Ŝĥőŵ mőřę",
|
||||
"today": "Ŧőđäy",
|
||||
"yesterday": "Ÿęşŧęřđäy"
|
||||
},
|
||||
"home": {
|
||||
"title": "Ħőmę"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user