diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index 15a952429f1..55ec8cf7490 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -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(HISTORY_LOCAL_STORAGE_KEY, []); + const clickedHistory = store.getObject('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; diff --git a/public/app/core/components/AppChrome/History/HistoryContainer.tsx b/public/app/core/components/AppChrome/History/HistoryContainer.tsx new file mode 100644 index 00000000000..5d8482a3697 --- /dev/null +++ b/public/app/core/components/AppChrome/History/HistoryContainer.tsx @@ -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('CLICKING_HISTORY'); + if (clickedHistory) { + store.setObject('CLICKING_HISTORY', false); + return; + } + const history = store.getObject(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 ( + <> + + + {showHistoryDrawer && ( + + onToggleShowHistoryDrawer(false)} /> + + )} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + separator: css({ + [theme.breakpoints.down('sm')]: { + display: 'none', + }, + }), + }; +}; diff --git a/public/app/core/components/AppChrome/History/HistoryWrapper.tsx b/public/app/core/components/AppChrome/History/HistoryWrapper.tsx new file mode 100644 index 00000000000..687f631fd8c --- /dev/null +++ b/public/app/core/components/AppChrome/History/HistoryWrapper.tsx @@ -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(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 ( + + + {Object.keys(hist).map((entries, date) => { + return ( + + + {entries} + + {hist[entries].map((entry, index) => { + return ( + onClose()} + /> + ); + })} + + ); + })} + + {history.length > numItemsToShow && ( + + )} + + ); +} +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 ( + + + {views.length > 0 ? ( + setIsExpanded(!isExpanded)} + aria-label={expandedLabel} + className={styles.iconButton} + /> + ) : ( + + )} + + { + store.setObject('CLICKING_HISTORY', true); + onClick(); + }} + href={url} + isCompact={true} + className={isSelected ? undefined : styles.card} + > + +
+ {breadcrumbs.map((breadcrumb, index) => ( + + {breadcrumb.text} {index !== breadcrumbs.length - 1 ? '> ' : ''} + + ))} +
+ {moment(time).format('h:mm A')} + {sparklineData && ( + + )} +
+
+
+ {isExpanded && ( +
+ {views.map((view, index) => { + return ( + { + store.setObject('CLICKING_HISTORY', true); + onClick(); + }} + isCompact={true} + className={view.time === selectedViewTime ? undefined : styles.card} + > + + {view.name} + {view.description && ( + + {view.description} + + )} + + + ); + })} +
+ )} +
+ ); +} +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, + }, + }), + }; +}; diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index 8b9b61cf41e..2bee868f11f 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -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 (
@@ -72,6 +74,7 @@ export const SingleTopBar = memo(function SingleTopBar({ + {unifiedHistoryEnabled && } {enrichedHelpNode && ( } placement="bottom-end"> diff --git a/public/app/core/components/AppChrome/types.ts b/public/app/core/components/AppChrome/types.ts index d307c26d351..1882e24d9a8 100644 --- a/public/app/core/components/AppChrome/types.ts +++ b/public/app/core/components/AppChrome/types.ts @@ -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; +} diff --git a/public/app/core/components/Breadcrumbs/utils.ts b/public/app/core/components/Breadcrumbs/utils.ts index 2719ec92b48..9df7bc79221 100644 --- a/public/app/core/components/Breadcrumbs/utils.ts +++ b/public/app/core/components/Breadcrumbs/utils.ts @@ -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) { - crumbs.unshift({ text: homeNav.text, href: node.url ?? '' }); + if (!skipHome) { + crumbs.unshift({ text: homeNav.text, href: node.url ?? '' }); + } foundHome = true; return; } diff --git a/public/app/features/trails/DataTrailsHistory.tsx b/public/app/features/trails/DataTrailsHistory.tsx index 26b3d6b4e05..aa66384eb4f 100644 --- a/public/app/features/trails/DataTrailsHistory.tsx +++ b/public/app/features/trails/DataTrailsHistory.tsx @@ -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,15 +151,24 @@ export class DataTrailHistory extends SceneObjectBase { return; } - this.addTrailStep( - trail, - 'time', - parseTimeTooltip({ - from: newState.from, - to: newState.to, - timeZone: newState.timeZone, - }) - ); + 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(), + }) + ); + } } } }); diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 0b03e21b20d..eb45e260f43 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -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 { export class PanelEditExitedEvent extends BusEventWithPayload { static type = 'panel-edit-finished'; } + +export class RecordHistoryEntryEvent extends BusEventWithPayload { + static type = 'record-history-entry'; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index db81976f539..b189cfd388b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index cff6f710dd9..49fc10fb067 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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ę" },