mirror of https://github.com/grafana/grafana.git
				
				
				
			Logs: Add log line to content outline when clicking on datalinks (#90207)
* feat: add bg color to pinned logs, pin logs when opening datalinks
This commit is contained in:
		
							parent
							
								
									2d35b11323
								
							
						
					
					
						commit
						1367d5d721
					
				|  | @ -0,0 +1,44 @@ | ||||||
|  | import { LogLevel } from '@grafana/data'; | ||||||
|  | import { reportInteraction } from '@grafana/runtime'; | ||||||
|  | 
 | ||||||
|  | export function contentOutlineTrackPinAdded() { | ||||||
|  |   reportInteraction('explore_toolbar_contentoutline_clicked', { | ||||||
|  |     item: 'section', | ||||||
|  |     type: 'Logs:pinned:pinned-log-added', | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function contentOutlineTrackPinRemoved() { | ||||||
|  |   reportInteraction('explore_toolbar_contentoutline_clicked', { | ||||||
|  |     item: 'section', | ||||||
|  |     type: 'Logs:pinned:pinned-log-deleted', | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function contentOutlineTrackPinLimitReached() { | ||||||
|  |   reportInteraction('explore_toolbar_contentoutline_clicked', { | ||||||
|  |     item: 'section', | ||||||
|  |     type: 'Logs:pinned:pinned-log-limit-reached', | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function contentOutlineTrackPinClicked() { | ||||||
|  |   reportInteraction('explore_toolbar_contentoutline_clicked', { | ||||||
|  |     item: 'section', | ||||||
|  |     type: 'Logs:pinned:pinned-log-clicked', | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function contentOutlineTrackUnpinClicked() { | ||||||
|  |   reportInteraction('explore_toolbar_contentoutline_clicked', { | ||||||
|  |     item: 'section', | ||||||
|  |     type: 'Logs:pinned:pinned-log-deleted', | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function contentOutlineTrackLevelFilter(level: { levelStr: string; logLevel: LogLevel }) { | ||||||
|  |   reportInteraction('explore_toolbar_contentoutline_clicked', { | ||||||
|  |     item: 'section', | ||||||
|  |     type: `Logs:filter:${level.levelStr}`, | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| import { uniqueId } from 'lodash'; | import { uniqueId } from 'lodash'; | ||||||
| import { useState, useContext, createContext, ReactNode, useCallback, useRef, useEffect } from 'react'; | import { useState, useContext, createContext, ReactNode, useCallback, useRef, useEffect } from 'react'; | ||||||
|  | import { SetOptional } from 'type-fest'; | ||||||
| 
 | 
 | ||||||
| import { ContentOutlineItemBaseProps, ITEM_TYPES } from './ContentOutlineItem'; | import { ContentOutlineItemBaseProps, ITEM_TYPES } from './ContentOutlineItem'; | ||||||
| 
 | 
 | ||||||
|  | @ -10,7 +11,7 @@ export interface ContentOutlineItemContextProps extends ContentOutlineItemBasePr | ||||||
|   children?: ContentOutlineItemContextProps[]; |   children?: ContentOutlineItemContextProps[]; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type RegisterFunction = (outlineItem: Omit<ContentOutlineItemContextProps, 'id'>) => string; | type RegisterFunction = (outlineItem: SetOptional<ContentOutlineItemContextProps, 'id'>) => string; | ||||||
| 
 | 
 | ||||||
| export interface ContentOutlineContextProps { | export interface ContentOutlineContextProps { | ||||||
|   outlineItems: ContentOutlineItemContextProps[]; |   outlineItems: ContentOutlineItemContextProps[]; | ||||||
|  | @ -44,7 +45,10 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }: | ||||||
|   const parentlessItemsRef = useRef<ParentlessItems>({}); |   const parentlessItemsRef = useRef<ParentlessItems>({}); | ||||||
| 
 | 
 | ||||||
|   const register: RegisterFunction = useCallback((outlineItem) => { |   const register: RegisterFunction = useCallback((outlineItem) => { | ||||||
|     const id = uniqueId(`${outlineItem.panelId}-${outlineItem.title}-${outlineItem.icon}_`); |     // Allow the caller to define unique ID so the outlineItem can be differentiated
 | ||||||
|  |     const id = outlineItem.id | ||||||
|  |       ? outlineItem.id | ||||||
|  |       : uniqueId(`${outlineItem.panelId}-${outlineItem.title}-${outlineItem.icon}_`); | ||||||
| 
 | 
 | ||||||
|     setOutlineItems((prevItems) => { |     setOutlineItems((prevItems) => { | ||||||
|       if (outlineItem.level === 'root') { |       if (outlineItem.level === 'root') { | ||||||
|  |  | ||||||
|  | @ -61,6 +61,14 @@ import { getLogLevel, getLogLevelFromKey, getLogLevelInfo } from 'app/features/l | ||||||
| import { getState } from 'app/store/store'; | import { getState } from 'app/store/store'; | ||||||
| import { ExploreItemState, useDispatch } from 'app/types'; | import { ExploreItemState, useDispatch } from 'app/types'; | ||||||
| 
 | 
 | ||||||
|  | import { | ||||||
|  |   contentOutlineTrackLevelFilter, | ||||||
|  |   contentOutlineTrackPinAdded, | ||||||
|  |   contentOutlineTrackPinClicked, | ||||||
|  |   contentOutlineTrackPinLimitReached, | ||||||
|  |   contentOutlineTrackPinRemoved, | ||||||
|  |   contentOutlineTrackUnpinClicked, | ||||||
|  | } from '../ContentOutline/ContentOutlineAnalyticEvents'; | ||||||
| import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext'; | import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext'; | ||||||
| import { getUrlStateFromPaneState } from '../hooks/useStateSync'; | import { getUrlStateFromPaneState } from '../hooks/useStateSync'; | ||||||
| import { changePanelState } from '../state/explorePane'; | import { changePanelState } from '../state/explorePane'; | ||||||
|  | @ -145,7 +153,10 @@ const getDefaultVisualisationType = (): LogsVisualisationType => { | ||||||
|   return 'logs'; |   return 'logs'; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const PINNED_LOGS_LIMIT = 3; | const PINNED_LOGS_LIMIT = 10; | ||||||
|  | const PINNED_LOGS_TITLE = 'Pinned log'; | ||||||
|  | const PINNED_LOGS_MESSAGE = 'Pin to content outline'; | ||||||
|  | const PINNED_LOGS_PANELID = 'Logs'; | ||||||
| 
 | 
 | ||||||
| const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => { | const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => { | ||||||
|   const { |   const { | ||||||
|  | @ -194,7 +205,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => { | ||||||
|   const [forceEscape, setForceEscape] = useState<boolean>(false); |   const [forceEscape, setForceEscape] = useState<boolean>(false); | ||||||
|   const [contextOpen, setContextOpen] = useState<boolean>(false); |   const [contextOpen, setContextOpen] = useState<boolean>(false); | ||||||
|   const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined); |   const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined); | ||||||
|   const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState<PopoverContent>('Pin to content outline'); |   const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState<PopoverContent>(PINNED_LOGS_MESSAGE); | ||||||
|   const [visualisationType, setVisualisationType] = useState<LogsVisualisationType | undefined>( |   const [visualisationType, setVisualisationType] = useState<LogsVisualisationType | undefined>( | ||||||
|     panelState?.logs?.visualisationType ?? getDefaultVisualisationType() |     panelState?.logs?.visualisationType ?? getDefaultVisualisationType() | ||||||
|   ); |   ); | ||||||
|  | @ -215,6 +226,17 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => { | ||||||
|   const hasData = logRows && logRows.length > 0; |   const hasData = logRows && logRows.length > 0; | ||||||
|   const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; |   const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; | ||||||
| 
 | 
 | ||||||
|  |   // Get pinned log lines
 | ||||||
|  |   const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); | ||||||
|  |   const pinnedLogs = logsParent?.children | ||||||
|  |     ?.filter((outlines) => outlines.title === PINNED_LOGS_TITLE) | ||||||
|  |     .map((pinnedLogs) => pinnedLogs.id); | ||||||
|  | 
 | ||||||
|  |   const getPinnedLogsCount = useCallback(() => { | ||||||
|  |     const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); | ||||||
|  |     return logsParent?.children?.filter((child) => child.title === PINNED_LOGS_TITLE).length ?? 0; | ||||||
|  |   }, [outlineItems]); | ||||||
|  | 
 | ||||||
|   const registerLogLevelsWithContentOutline = useCallback(() => { |   const registerLogLevelsWithContentOutline = useCallback(() => { | ||||||
|     const levelsArr = Object.keys(LogLevelColor); |     const levelsArr = Object.keys(LogLevelColor); | ||||||
|     const logVolumeDataFrames = new Set(logsVolumeData?.data); |     const logVolumeDataFrames = new Set(logsVolumeData?.data); | ||||||
|  | @ -228,7 +250,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => { | ||||||
|     // clean up all current log levels
 |     // clean up all current log levels
 | ||||||
|     if (unregisterAllChildren) { |     if (unregisterAllChildren) { | ||||||
|       unregisterAllChildren((items) => { |       unregisterAllChildren((items) => { | ||||||
|         const logsParent = items?.find((item) => item.panelId === 'Logs' && item.level === 'root'); |         const logsParent = items?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); | ||||||
|         return logsParent?.id; |         return logsParent?.id; | ||||||
|       }, 'filter'); |       }, 'filter'); | ||||||
|     } |     } | ||||||
|  | @ -256,16 +278,13 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => { | ||||||
|           register({ |           register({ | ||||||
|             title: level.levelStr, |             title: level.levelStr, | ||||||
|             icon: 'gf-logs', |             icon: 'gf-logs', | ||||||
|             panelId: 'Logs', |             panelId: PINNED_LOGS_PANELID, | ||||||
|             level: 'child', |             level: 'child', | ||||||
|             type: 'filter', |             type: 'filter', | ||||||
|             highlight: currentLevelSelected && !allLevelsSelected, |             highlight: currentLevelSelected && !allLevelsSelected, | ||||||
|             onClick: (e: React.MouseEvent) => { |             onClick: (e: React.MouseEvent) => { | ||||||
|               toggleLegendRef.current?.(level.levelStr, mapMouseEventToMode(e)); |               toggleLegendRef.current?.(level.levelStr, mapMouseEventToMode(e)); | ||||||
|               reportInteraction('explore_toolbar_contentoutline_clicked', { |               contentOutlineTrackLevelFilter(level); | ||||||
|                 item: 'section', |  | ||||||
|                 type: `Logs:filter:${level.levelStr}`, |  | ||||||
|               }); |  | ||||||
|             }, |             }, | ||||||
|             ref: null, |             ref: null, | ||||||
|             color: LogLevelColor[level.logLevel], |             color: LogLevelColor[level.logLevel], | ||||||
|  | @ -275,6 +294,21 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => { | ||||||
|     } |     } | ||||||
|   }, [logsVolumeData?.data, unregisterAllChildren, logsVolumeEnabled, hiddenLogLevels, register, toggleLegendRef]); |   }, [logsVolumeData?.data, unregisterAllChildren, logsVolumeEnabled, hiddenLogLevels, register, toggleLegendRef]); | ||||||
| 
 | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (getPinnedLogsCount() === PINNED_LOGS_LIMIT) { | ||||||
|  |       setPinLineButtonTooltipTitle( | ||||||
|  |         <span style={{ display: 'flex', textAlign: 'center' }}> | ||||||
|  |           ❗️ | ||||||
|  |           <Trans i18nKey="explore.logs.maximum-pinned-logs"> | ||||||
|  |             Maximum of {{ PINNED_LOGS_LIMIT }} pinned logs reached. Unpin a log to add another. | ||||||
|  |           </Trans> | ||||||
|  |         </span> | ||||||
|  |       ); | ||||||
|  |     } else { | ||||||
|  |       setPinLineButtonTooltipTitle(PINNED_LOGS_MESSAGE); | ||||||
|  |     } | ||||||
|  |   }, [outlineItems, getPinnedLogsCount]); | ||||||
|  | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (loading && !previousLoading && panelState?.logs?.id) { |     if (loading && !previousLoading && panelState?.logs?.id) { | ||||||
|       // loading stopped, so we need to remove any permalinked log lines
 |       // loading stopped, so we need to remove any permalinked log lines
 | ||||||
|  | @ -652,70 +686,47 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => { | ||||||
|     topLogsRef.current?.scrollIntoView(); |     topLogsRef.current?.scrollIntoView(); | ||||||
|   }, [logsContainerRef, topLogsRef]); |   }, [logsContainerRef, topLogsRef]); | ||||||
| 
 | 
 | ||||||
|   const onPinToContentOutlineClick = (row: LogRowModel) => { |   const onPinToContentOutlineClick = (row: LogRowModel, allowUnPin = true) => { | ||||||
|     if (getPinnedLogsCount() === PINNED_LOGS_LIMIT) { |     if (getPinnedLogsCount() === PINNED_LOGS_LIMIT && !allowUnPin) { | ||||||
|       setPinLineButtonTooltipTitle( |       contentOutlineTrackPinLimitReached(); | ||||||
|         <span style={{ display: 'flex', textAlign: 'center' }}> |  | ||||||
|           ❗️ |  | ||||||
|           <Trans i18nKey="explore.logs.maximum-pinned-logs"> |  | ||||||
|             Maximum of {{ PINNED_LOGS_LIMIT }} pinned logs reached. Unpin a log to add another. |  | ||||||
|           </Trans> |  | ||||||
|         </span> |  | ||||||
|       ); |  | ||||||
| 
 |  | ||||||
|       reportInteraction('explore_toolbar_contentoutline_clicked', { |  | ||||||
|         item: 'section', |  | ||||||
|         type: 'Logs:pinned:pinned-log-limit-reached', |  | ||||||
|       }); |  | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // find the Logs parent item
 |     // find the Logs parent item
 | ||||||
|     const logsParent = outlineItems?.find((item) => item.panelId === 'Logs' && item.level === 'root'); |     const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); | ||||||
| 
 | 
 | ||||||
|     //update the parent's expanded state
 |     //update the parent's expanded state
 | ||||||
|     if (logsParent && updateItem) { |     if (logsParent && updateItem) { | ||||||
|       updateItem(logsParent.id, { expanded: true }); |       updateItem(logsParent.id, { expanded: true }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     register?.({ |     const alreadyPinned = pinnedLogs?.find((pin) => pin === row.rowId); | ||||||
|       icon: 'gf-logs', |     if (alreadyPinned && row.rowId && allowUnPin) { | ||||||
|       title: 'Pinned log', |       unregister?.(row.rowId); | ||||||
|       panelId: 'Logs', |       contentOutlineTrackPinRemoved(); | ||||||
|       level: 'child', |     } else if (getPinnedLogsCount() !== PINNED_LOGS_LIMIT && !alreadyPinned) { | ||||||
|       ref: null, |       register?.({ | ||||||
|       color: LogLevelColor[row.logLevel], |         id: row.rowId, | ||||||
|       childOnTop: true, |         icon: 'gf-logs', | ||||||
|       onClick: () => { |         title: PINNED_LOGS_TITLE, | ||||||
|         onOpenContext(row, () => {}); |         panelId: PINNED_LOGS_PANELID, | ||||||
|         reportInteraction('explore_toolbar_contentoutline_clicked', { |         level: 'child', | ||||||
|           item: 'section', |         ref: null, | ||||||
|           type: 'Logs:pinned:pinned-log-clicked', |         color: LogLevelColor[row.logLevel], | ||||||
|         }); |         childOnTop: true, | ||||||
|       }, |         onClick: () => { | ||||||
|       onRemove: (id: string) => { |           onOpenContext(row, () => {}); | ||||||
|         unregister?.(id); |           contentOutlineTrackPinClicked(); | ||||||
|         if (getPinnedLogsCount() < PINNED_LOGS_LIMIT) { |         }, | ||||||
|           setPinLineButtonTooltipTitle('Pin to content outline'); |         onRemove: (id: string) => { | ||||||
|         } |           unregister?.(id); | ||||||
|         reportInteraction('explore_toolbar_contentoutline_clicked', { |           contentOutlineTrackUnpinClicked(); | ||||||
|           item: 'section', |         }, | ||||||
|           type: 'Logs:pinned:pinned-log-deleted', |       }); | ||||||
|         }); |       contentOutlineTrackPinAdded(); | ||||||
|       }, |     } | ||||||
|     }); |  | ||||||
| 
 | 
 | ||||||
|     props.onPinLineCallback?.(); |     props.onPinLineCallback?.(); | ||||||
| 
 |  | ||||||
|     reportInteraction('explore_toolbar_contentoutline_clicked', { |  | ||||||
|       item: 'section', |  | ||||||
|       type: 'Logs:pinned:pinned-log-added', |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const getPinnedLogsCount = () => { |  | ||||||
|     const logsParent = outlineItems?.find((item) => item.panelId === 'Logs' && item.level === 'root'); |  | ||||||
|     return logsParent?.children?.filter((child) => child.title === 'Pinned log').length ?? 0; |  | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const hasUnescapedContent = checkUnescapedContent(logRows); |   const hasUnescapedContent = checkUnescapedContent(logRows); | ||||||
|  | @ -929,6 +940,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => { | ||||||
|                 sortOrder={logsSortOrder} |                 sortOrder={logsSortOrder} | ||||||
|               > |               > | ||||||
|                 <LogRows |                 <LogRows | ||||||
|  |                   pinnedLogs={pinnedLogs} | ||||||
|                   logRows={logRows} |                   logRows={logRows} | ||||||
|                   deduplicatedRows={dedupedRows} |                   deduplicatedRows={dedupedRows} | ||||||
|                   dedupStrategy={dedupStrategy} |                   dedupStrategy={dedupStrategy} | ||||||
|  | @ -958,6 +970,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => { | ||||||
|                   containerRendered={!!logsContainerRef} |                   containerRendered={!!logsContainerRef} | ||||||
|                   onClickFilterString={props.onClickFilterString} |                   onClickFilterString={props.onClickFilterString} | ||||||
|                   onClickFilterOutString={props.onClickFilterOutString} |                   onClickFilterOutString={props.onClickFilterOutString} | ||||||
|  |                   onUnpinLine={onPinToContentOutlineClick} | ||||||
|                   onPinLine={onPinToContentOutlineClick} |                   onPinLine={onPinToContentOutlineClick} | ||||||
|                   pinLineButtonTooltipTitle={pinLineButtonTooltipTitle} |                   pinLineButtonTooltipTitle={pinLineButtonTooltipTitle} | ||||||
|                 /> |                 /> | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ import { cx } from '@emotion/css'; | ||||||
| import { PureComponent } from 'react'; | import { PureComponent } from 'react'; | ||||||
| 
 | 
 | ||||||
| import { CoreApp, DataFrame, DataFrameType, Field, LinkModel, LogRowModel } from '@grafana/data'; | import { CoreApp, DataFrame, DataFrameType, Field, LinkModel, LogRowModel } from '@grafana/data'; | ||||||
| import { Themeable2, withTheme2 } from '@grafana/ui'; | import { PopoverContent, Themeable2, withTheme2 } from '@grafana/ui'; | ||||||
| 
 | 
 | ||||||
| import { calculateLogsLabelStats, calculateStats } from '../utils'; | import { calculateLogsLabelStats, calculateStats } from '../utils'; | ||||||
| 
 | 
 | ||||||
|  | @ -27,6 +27,9 @@ export interface Props extends Themeable2 { | ||||||
|   onClickShowField?: (key: string) => void; |   onClickShowField?: (key: string) => void; | ||||||
|   onClickHideField?: (key: string) => void; |   onClickHideField?: (key: string) => void; | ||||||
|   isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>; |   isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>; | ||||||
|  | 
 | ||||||
|  |   onPinLine?: (row: LogRowModel) => void; | ||||||
|  |   pinLineButtonTooltipTitle?: PopoverContent; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| class UnThemedLogDetails extends PureComponent<Props> { | class UnThemedLogDetails extends PureComponent<Props> { | ||||||
|  | @ -46,7 +49,9 @@ class UnThemedLogDetails extends PureComponent<Props> { | ||||||
|       displayedFields, |       displayedFields, | ||||||
|       getFieldLinks, |       getFieldLinks, | ||||||
|       wrapLogMessage, |       wrapLogMessage, | ||||||
|  |       onPinLine, | ||||||
|       styles, |       styles, | ||||||
|  |       pinLineButtonTooltipTitle, | ||||||
|     } = this.props; |     } = this.props; | ||||||
|     const levelStyles = getLogLevelStyles(theme, row.logLevel); |     const levelStyles = getLogLevelStyles(theme, row.logLevel); | ||||||
|     const labels = row.labels ? row.labels : {}; |     const labels = row.labels ? row.labels : {}; | ||||||
|  | @ -151,6 +156,8 @@ class UnThemedLogDetails extends PureComponent<Props> { | ||||||
|                       links={links} |                       links={links} | ||||||
|                       onClickShowField={onClickShowField} |                       onClickShowField={onClickShowField} | ||||||
|                       onClickHideField={onClickHideField} |                       onClickHideField={onClickHideField} | ||||||
|  |                       onPinLine={onPinLine} | ||||||
|  |                       pinLineButtonTooltipTitle={pinLineButtonTooltipTitle} | ||||||
|                       getStats={() => calculateStats(row.dataFrame.fields[fieldIndex].values)} |                       getStats={() => calculateStats(row.dataFrame.fields[fieldIndex].values)} | ||||||
|                       displayedFields={displayedFields} |                       displayedFields={displayedFields} | ||||||
|                       wrapLogMessage={wrapLogMessage} |                       wrapLogMessage={wrapLogMessage} | ||||||
|  | @ -170,6 +177,8 @@ class UnThemedLogDetails extends PureComponent<Props> { | ||||||
|                       links={links} |                       links={links} | ||||||
|                       onClickShowField={onClickShowField} |                       onClickShowField={onClickShowField} | ||||||
|                       onClickHideField={onClickHideField} |                       onClickHideField={onClickHideField} | ||||||
|  |                       onPinLine={onPinLine} | ||||||
|  |                       pinLineButtonTooltipTitle={pinLineButtonTooltipTitle} | ||||||
|                       getStats={() => calculateStats(row.dataFrame.fields[fieldIndex].values)} |                       getStats={() => calculateStats(row.dataFrame.fields[fieldIndex].values)} | ||||||
|                       displayedFields={displayedFields} |                       displayedFields={displayedFields} | ||||||
|                       wrapLogMessage={wrapLogMessage} |                       wrapLogMessage={wrapLogMessage} | ||||||
|  |  | ||||||
|  | @ -1,7 +1,8 @@ | ||||||
| import { fireEvent, render, screen } from '@testing-library/react'; | import { fireEvent, render, screen } from '@testing-library/react'; | ||||||
| import { ComponentProps } from 'react'; | import { ComponentProps } from 'react'; | ||||||
| 
 | 
 | ||||||
| import { CoreApp } from '@grafana/data'; | import { CoreApp, FieldType, LinkModel } from '@grafana/data'; | ||||||
|  | import { Field } from '@grafana/data/'; | ||||||
| 
 | 
 | ||||||
| import { LogDetailsRow } from './LogDetailsRow'; | import { LogDetailsRow } from './LogDetailsRow'; | ||||||
| import { createLogRow } from './__mocks__/logRow'; | import { createLogRow } from './__mocks__/logRow'; | ||||||
|  | @ -147,4 +148,34 @@ describe('LogDetailsRow', () => { | ||||||
|       // Asserting visibility on mouse-over is currently not possible.
 |       // Asserting visibility on mouse-over is currently not possible.
 | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  | 
 | ||||||
|  |   describe('datalinks', () => { | ||||||
|  |     it('datalinks should pin and call the original link click', () => { | ||||||
|  |       const onLinkClick = jest.fn(); | ||||||
|  |       const onPinLine = jest.fn(); | ||||||
|  |       const links: Array<LinkModel<Field>> = [ | ||||||
|  |         { | ||||||
|  |           onClick: onLinkClick, | ||||||
|  |           href: '#', | ||||||
|  |           title: 'Hello link', | ||||||
|  |           target: '_self', | ||||||
|  |           origin: { | ||||||
|  |             name: 'name', | ||||||
|  |             type: FieldType.string, | ||||||
|  |             config: {}, | ||||||
|  |             values: ['string'], | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       ]; | ||||||
|  |       setup({ links, onPinLine }); | ||||||
|  | 
 | ||||||
|  |       expect(onLinkClick).not.toHaveBeenCalled(); | ||||||
|  |       expect(onPinLine).not.toHaveBeenCalled(); | ||||||
|  | 
 | ||||||
|  |       fireEvent.click(screen.getByRole('button', { name: 'Hello link' })); | ||||||
|  | 
 | ||||||
|  |       expect(onLinkClick).toHaveBeenCalled(); | ||||||
|  |       expect(onPinLine).toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -15,7 +15,7 @@ import { | ||||||
|   LogRowModel, |   LogRowModel, | ||||||
| } from '@grafana/data'; | } from '@grafana/data'; | ||||||
| import { reportInteraction } from '@grafana/runtime'; | import { reportInteraction } from '@grafana/runtime'; | ||||||
| import { ClipboardButton, DataLinkButton, IconButton, Themeable2, withTheme2 } from '@grafana/ui'; | import { ClipboardButton, DataLinkButton, IconButton, PopoverContent, Themeable2, withTheme2 } from '@grafana/ui'; | ||||||
| 
 | 
 | ||||||
| import { logRowToSingleRowDataFrame } from '../logsModel'; | import { logRowToSingleRowDataFrame } from '../logsModel'; | ||||||
| 
 | 
 | ||||||
|  | @ -38,6 +38,8 @@ export interface Props extends Themeable2 { | ||||||
|   row: LogRowModel; |   row: LogRowModel; | ||||||
|   app?: CoreApp; |   app?: CoreApp; | ||||||
|   isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>; |   isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>; | ||||||
|  |   onPinLine?: (row: LogRowModel, allowUnPin?: boolean) => void; | ||||||
|  |   pinLineButtonTooltipTitle?: PopoverContent; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| interface State { | interface State { | ||||||
|  | @ -263,6 +265,8 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> { | ||||||
|       disableActions, |       disableActions, | ||||||
|       row, |       row, | ||||||
|       app, |       app, | ||||||
|  |       onPinLine, | ||||||
|  |       pinLineButtonTooltipTitle, | ||||||
|     } = this.props; |     } = this.props; | ||||||
|     const { showFieldsStats, fieldStats, fieldCount } = this.state; |     const { showFieldsStats, fieldStats, fieldCount } = this.state; | ||||||
|     const styles = getStyles(theme); |     const styles = getStyles(theme); | ||||||
|  | @ -324,11 +328,32 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> { | ||||||
|               {singleVal ? parsedValues[0] : this.generateMultiVal(parsedValues, true)} |               {singleVal ? parsedValues[0] : this.generateMultiVal(parsedValues, true)} | ||||||
|               {singleVal && this.generateClipboardButton(parsedValues[0])} |               {singleVal && this.generateClipboardButton(parsedValues[0])} | ||||||
|               <div className={cx((singleVal || isMultiParsedValueWithNoContent) && styles.adjoiningLinkButton)}> |               <div className={cx((singleVal || isMultiParsedValueWithNoContent) && styles.adjoiningLinkButton)}> | ||||||
|                 {links?.map((link, i) => ( |                 {links?.map((link, i) => { | ||||||
|                   <span key={`${link.title}-${i}`}> |                   if (link.onClick && onPinLine) { | ||||||
|                     <DataLinkButton link={link} /> |                     const originalOnClick = link.onClick; | ||||||
|                   </span> |                     link.onClick = (e, origin) => { | ||||||
|                 ))} |                       // Pin the line
 | ||||||
|  |                       onPinLine(row, false); | ||||||
|  | 
 | ||||||
|  |                       // Execute the link onClick function
 | ||||||
|  |                       originalOnClick(e, origin); | ||||||
|  |                     }; | ||||||
|  |                   } | ||||||
|  |                   return ( | ||||||
|  |                     <span key={`${link.title}-${i}`}> | ||||||
|  |                       <DataLinkButton | ||||||
|  |                         buttonProps={{ | ||||||
|  |                           // Show tooltip message if max number of pinned lines has been reached
 | ||||||
|  |                           tooltip: | ||||||
|  |                             typeof pinLineButtonTooltipTitle === 'object' && link.onClick | ||||||
|  |                               ? pinLineButtonTooltipTitle | ||||||
|  |                               : undefined, | ||||||
|  |                         }} | ||||||
|  |                         link={link} | ||||||
|  |                       /> | ||||||
|  |                     </span> | ||||||
|  |                   ); | ||||||
|  |                 })} | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|           </td> |           </td> | ||||||
|  |  | ||||||
|  | @ -1,24 +1,24 @@ | ||||||
| import { cx } from '@emotion/css'; | import { cx } from '@emotion/css'; | ||||||
| import { debounce } from 'lodash'; | import { debounce } from 'lodash'; | ||||||
| import memoizeOne from 'memoize-one'; | import memoizeOne from 'memoize-one'; | ||||||
| import { PureComponent, MouseEvent } from 'react'; |  | ||||||
| import * as React from 'react'; | import * as React from 'react'; | ||||||
|  | import { MouseEvent, PureComponent } from 'react'; | ||||||
| 
 | 
 | ||||||
| import { | import { | ||||||
|   Field, |  | ||||||
|   LinkModel, |  | ||||||
|   LogRowModel, |  | ||||||
|   LogsSortOrder, |  | ||||||
|   dateTimeFormat, |  | ||||||
|   CoreApp, |   CoreApp, | ||||||
|   DataFrame, |   DataFrame, | ||||||
|  |   dateTimeFormat, | ||||||
|  |   Field, | ||||||
|  |   LinkModel, | ||||||
|   LogRowContextOptions, |   LogRowContextOptions, | ||||||
|  |   LogRowModel, | ||||||
|  |   LogsSortOrder, | ||||||
| } from '@grafana/data'; | } from '@grafana/data'; | ||||||
| import { reportInteraction } from '@grafana/runtime'; | import { reportInteraction } from '@grafana/runtime'; | ||||||
| import { DataQuery, TimeZone } from '@grafana/schema'; | import { DataQuery, TimeZone } from '@grafana/schema'; | ||||||
| import { withTheme2, Themeable2, Icon, Tooltip, PopoverContent } from '@grafana/ui'; | import { Icon, PopoverContent, Themeable2, Tooltip, withTheme2 } from '@grafana/ui'; | ||||||
| 
 | 
 | ||||||
| import { checkLogsError, escapeUnescapedString, checkLogsSampled } from '../utils'; | import { checkLogsError, checkLogsSampled, escapeUnescapedString } from '../utils'; | ||||||
| 
 | 
 | ||||||
| import { LogDetails } from './LogDetails'; | import { LogDetails } from './LogDetails'; | ||||||
| import { LogLabels } from './LogLabels'; | import { LogLabels } from './LogLabels'; | ||||||
|  | @ -59,7 +59,7 @@ interface Props extends Themeable2 { | ||||||
|   permalinkedRowId?: string; |   permalinkedRowId?: string; | ||||||
|   scrollIntoView?: (element: HTMLElement) => void; |   scrollIntoView?: (element: HTMLElement) => void; | ||||||
|   isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>; |   isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>; | ||||||
|   onPinLine?: (row: LogRowModel) => void; |   onPinLine?: (row: LogRowModel, allowUnPin?: boolean) => void; | ||||||
|   onUnpinLine?: (row: LogRowModel) => void; |   onUnpinLine?: (row: LogRowModel) => void; | ||||||
|   pinLineButtonTooltipTitle?: PopoverContent; |   pinLineButtonTooltipTitle?: PopoverContent; | ||||||
|   pinned?: boolean; |   pinned?: boolean; | ||||||
|  | @ -219,14 +219,16 @@ class UnThemedLogRow extends PureComponent<Props, State> { | ||||||
|       app, |       app, | ||||||
|       styles, |       styles, | ||||||
|       getRowContextQuery, |       getRowContextQuery, | ||||||
|  |       pinned, | ||||||
|     } = this.props; |     } = this.props; | ||||||
|  | 
 | ||||||
|     const { showDetails, showingContext, permalinked } = this.state; |     const { showDetails, showingContext, permalinked } = this.state; | ||||||
|     const levelStyles = getLogLevelStyles(theme, row.logLevel); |     const levelStyles = getLogLevelStyles(theme, row.logLevel); | ||||||
|     const { errorMessage, hasError } = checkLogsError(row); |     const { errorMessage, hasError } = checkLogsError(row); | ||||||
|     const { sampleMessage, isSampled } = checkLogsSampled(row); |     const { sampleMessage, isSampled } = checkLogsSampled(row); | ||||||
|     const logRowBackground = cx(styles.logsRow, { |     const logRowBackground = cx(styles.logsRow, { | ||||||
|       [styles.errorLogRow]: hasError, |       [styles.errorLogRow]: hasError, | ||||||
|       [styles.highlightBackground]: showingContext || permalinked, |       [styles.highlightBackground]: showingContext || permalinked || pinned, | ||||||
|     }); |     }); | ||||||
|     const logRowDetailsBackground = cx(styles.logsRow, { |     const logRowDetailsBackground = cx(styles.logsRow, { | ||||||
|       [styles.errorLogRow]: hasError, |       [styles.errorLogRow]: hasError, | ||||||
|  | @ -327,6 +329,7 @@ class UnThemedLogRow extends PureComponent<Props, State> { | ||||||
|         </tr> |         </tr> | ||||||
|         {this.state.showDetails && ( |         {this.state.showDetails && ( | ||||||
|           <LogDetails |           <LogDetails | ||||||
|  |             onPinLine={this.props.onPinLine} | ||||||
|             className={logRowDetailsBackground} |             className={logRowDetailsBackground} | ||||||
|             showDuplicates={showDuplicates} |             showDuplicates={showDuplicates} | ||||||
|             getFieldLinks={getFieldLinks} |             getFieldLinks={getFieldLinks} | ||||||
|  | @ -342,6 +345,7 @@ class UnThemedLogRow extends PureComponent<Props, State> { | ||||||
|             app={app} |             app={app} | ||||||
|             styles={styles} |             styles={styles} | ||||||
|             isFilterLabelActive={this.props.isFilterLabelActive} |             isFilterLabelActive={this.props.isFilterLabelActive} | ||||||
|  |             pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle} | ||||||
|           /> |           /> | ||||||
|         )} |         )} | ||||||
|       </> |       </> | ||||||
|  |  | ||||||
|  | @ -48,7 +48,7 @@ export interface Props extends Themeable2 { | ||||||
|   getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>; |   getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>; | ||||||
|   onClickShowField?: (key: string) => void; |   onClickShowField?: (key: string) => void; | ||||||
|   onClickHideField?: (key: string) => void; |   onClickHideField?: (key: string) => void; | ||||||
|   onPinLine?: (row: LogRowModel) => void; |   onPinLine?: (row: LogRowModel, allowUnPin?: boolean) => void; | ||||||
|   onUnpinLine?: (row: LogRowModel) => void; |   onUnpinLine?: (row: LogRowModel) => void; | ||||||
|   pinLineButtonTooltipTitle?: PopoverContent; |   pinLineButtonTooltipTitle?: PopoverContent; | ||||||
|   onLogRowHover?: (row?: LogRowModel) => void; |   onLogRowHover?: (row?: LogRowModel) => void; | ||||||
|  | @ -63,6 +63,7 @@ export interface Props extends Themeable2 { | ||||||
|   scrollIntoView?: (element: HTMLElement) => void; |   scrollIntoView?: (element: HTMLElement) => void; | ||||||
|   isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>; |   isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>; | ||||||
|   pinnedRowId?: string; |   pinnedRowId?: string; | ||||||
|  |   pinnedLogs?: string[]; | ||||||
|   containerRendered?: boolean; |   containerRendered?: boolean; | ||||||
|   /** |   /** | ||||||
|    * If false or undefined, the `contain:strict` css property will be added to the wrapping `<table>` for performance reasons. |    * If false or undefined, the `contain:strict` css property will be added to the wrapping `<table>` for performance reasons. | ||||||
|  | @ -191,7 +192,8 @@ class UnThemedLogRows extends PureComponent<Props, State> { | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   render() { |   render() { | ||||||
|     const { deduplicatedRows, logRows, dedupStrategy, theme, logsSortOrder, previewLimit, ...rest } = this.props; |     const { deduplicatedRows, logRows, dedupStrategy, theme, logsSortOrder, previewLimit, pinnedLogs, ...rest } = | ||||||
|  |       this.props; | ||||||
|     const { renderAll } = this.state; |     const { renderAll } = this.state; | ||||||
|     const styles = getLogRowStyles(theme); |     const styles = getLogRowStyles(theme); | ||||||
|     const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows; |     const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows; | ||||||
|  | @ -241,7 +243,7 @@ class UnThemedLogRows extends PureComponent<Props, State> { | ||||||
|                   onPinLine={this.props.onPinLine} |                   onPinLine={this.props.onPinLine} | ||||||
|                   onUnpinLine={this.props.onUnpinLine} |                   onUnpinLine={this.props.onUnpinLine} | ||||||
|                   pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle} |                   pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle} | ||||||
|                   pinned={this.props.pinnedRowId === row.uid} |                   pinned={this.props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)} | ||||||
|                   isFilterLabelActive={this.props.isFilterLabelActive} |                   isFilterLabelActive={this.props.isFilterLabelActive} | ||||||
|                   handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined} |                   handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined} | ||||||
|                   {...rest} |                   {...rest} | ||||||
|  | @ -264,7 +266,7 @@ class UnThemedLogRows extends PureComponent<Props, State> { | ||||||
|                   onPinLine={this.props.onPinLine} |                   onPinLine={this.props.onPinLine} | ||||||
|                   onUnpinLine={this.props.onUnpinLine} |                   onUnpinLine={this.props.onUnpinLine} | ||||||
|                   pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle} |                   pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle} | ||||||
|                   pinned={this.props.pinnedRowId === row.uid} |                   pinned={this.props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)} | ||||||
|                   isFilterLabelActive={this.props.isFilterLabelActive} |                   isFilterLabelActive={this.props.isFilterLabelActive} | ||||||
|                   handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined} |                   handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined} | ||||||
|                   {...rest} |                   {...rest} | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue