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:
Galen Kistler 2024-07-12 08:14:53 -05:00 committed by GitHub
parent 2d35b11323
commit 1367d5d721
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 216 additions and 84 deletions

View File

@ -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}`,
});
}

View File

@ -1,5 +1,6 @@
import { uniqueId } from 'lodash';
import { useState, useContext, createContext, ReactNode, useCallback, useRef, useEffect } from 'react';
import { SetOptional } from 'type-fest';
import { ContentOutlineItemBaseProps, ITEM_TYPES } from './ContentOutlineItem';
@ -10,7 +11,7 @@ export interface ContentOutlineItemContextProps extends ContentOutlineItemBasePr
children?: ContentOutlineItemContextProps[];
}
type RegisterFunction = (outlineItem: Omit<ContentOutlineItemContextProps, 'id'>) => string;
type RegisterFunction = (outlineItem: SetOptional<ContentOutlineItemContextProps, 'id'>) => string;
export interface ContentOutlineContextProps {
outlineItems: ContentOutlineItemContextProps[];
@ -44,7 +45,10 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }:
const parentlessItemsRef = useRef<ParentlessItems>({});
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) => {
if (outlineItem.level === 'root') {

View File

@ -61,6 +61,14 @@ import { getLogLevel, getLogLevelFromKey, getLogLevelInfo } from 'app/features/l
import { getState } from 'app/store/store';
import { ExploreItemState, useDispatch } from 'app/types';
import {
contentOutlineTrackLevelFilter,
contentOutlineTrackPinAdded,
contentOutlineTrackPinClicked,
contentOutlineTrackPinLimitReached,
contentOutlineTrackPinRemoved,
contentOutlineTrackUnpinClicked,
} from '../ContentOutline/ContentOutlineAnalyticEvents';
import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext';
import { getUrlStateFromPaneState } from '../hooks/useStateSync';
import { changePanelState } from '../state/explorePane';
@ -145,7 +153,10 @@ const getDefaultVisualisationType = (): LogsVisualisationType => {
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 {
@ -194,7 +205,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const [forceEscape, setForceEscape] = useState<boolean>(false);
const [contextOpen, setContextOpen] = useState<boolean>(false);
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>(
panelState?.logs?.visualisationType ?? getDefaultVisualisationType()
);
@ -215,6 +226,17 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const hasData = logRows && logRows.length > 0;
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 levelsArr = Object.keys(LogLevelColor);
const logVolumeDataFrames = new Set(logsVolumeData?.data);
@ -228,7 +250,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
// clean up all current log levels
if (unregisterAllChildren) {
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;
}, 'filter');
}
@ -256,16 +278,13 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
register({
title: level.levelStr,
icon: 'gf-logs',
panelId: 'Logs',
panelId: PINNED_LOGS_PANELID,
level: 'child',
type: 'filter',
highlight: currentLevelSelected && !allLevelsSelected,
onClick: (e: React.MouseEvent) => {
toggleLegendRef.current?.(level.levelStr, mapMouseEventToMode(e));
reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'section',
type: `Logs:filter:${level.levelStr}`,
});
contentOutlineTrackLevelFilter(level);
},
ref: null,
color: LogLevelColor[level.logLevel],
@ -275,6 +294,21 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
}
}, [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(() => {
if (loading && !previousLoading && panelState?.logs?.id) {
// 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();
}, [logsContainerRef, topLogsRef]);
const onPinToContentOutlineClick = (row: LogRowModel) => {
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>
);
reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'section',
type: 'Logs:pinned:pinned-log-limit-reached',
});
const onPinToContentOutlineClick = (row: LogRowModel, allowUnPin = true) => {
if (getPinnedLogsCount() === PINNED_LOGS_LIMIT && !allowUnPin) {
contentOutlineTrackPinLimitReached();
return;
}
// 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
if (logsParent && updateItem) {
updateItem(logsParent.id, { expanded: true });
}
register?.({
icon: 'gf-logs',
title: 'Pinned log',
panelId: 'Logs',
level: 'child',
ref: null,
color: LogLevelColor[row.logLevel],
childOnTop: true,
onClick: () => {
onOpenContext(row, () => {});
reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'section',
type: 'Logs:pinned:pinned-log-clicked',
});
},
onRemove: (id: string) => {
unregister?.(id);
if (getPinnedLogsCount() < PINNED_LOGS_LIMIT) {
setPinLineButtonTooltipTitle('Pin to content outline');
}
reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'section',
type: 'Logs:pinned:pinned-log-deleted',
});
},
});
const alreadyPinned = pinnedLogs?.find((pin) => pin === row.rowId);
if (alreadyPinned && row.rowId && allowUnPin) {
unregister?.(row.rowId);
contentOutlineTrackPinRemoved();
} else if (getPinnedLogsCount() !== PINNED_LOGS_LIMIT && !alreadyPinned) {
register?.({
id: row.rowId,
icon: 'gf-logs',
title: PINNED_LOGS_TITLE,
panelId: PINNED_LOGS_PANELID,
level: 'child',
ref: null,
color: LogLevelColor[row.logLevel],
childOnTop: true,
onClick: () => {
onOpenContext(row, () => {});
contentOutlineTrackPinClicked();
},
onRemove: (id: string) => {
unregister?.(id);
contentOutlineTrackUnpinClicked();
},
});
contentOutlineTrackPinAdded();
}
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);
@ -929,6 +940,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
sortOrder={logsSortOrder}
>
<LogRows
pinnedLogs={pinnedLogs}
logRows={logRows}
deduplicatedRows={dedupedRows}
dedupStrategy={dedupStrategy}
@ -958,6 +970,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
containerRendered={!!logsContainerRef}
onClickFilterString={props.onClickFilterString}
onClickFilterOutString={props.onClickFilterOutString}
onUnpinLine={onPinToContentOutlineClick}
onPinLine={onPinToContentOutlineClick}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
/>

View File

@ -2,7 +2,7 @@ import { cx } from '@emotion/css';
import { PureComponent } from 'react';
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';
@ -27,6 +27,9 @@ export interface Props extends Themeable2 {
onClickShowField?: (key: string) => void;
onClickHideField?: (key: string) => void;
isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
onPinLine?: (row: LogRowModel) => void;
pinLineButtonTooltipTitle?: PopoverContent;
}
class UnThemedLogDetails extends PureComponent<Props> {
@ -46,7 +49,9 @@ class UnThemedLogDetails extends PureComponent<Props> {
displayedFields,
getFieldLinks,
wrapLogMessage,
onPinLine,
styles,
pinLineButtonTooltipTitle,
} = this.props;
const levelStyles = getLogLevelStyles(theme, row.logLevel);
const labels = row.labels ? row.labels : {};
@ -151,6 +156,8 @@ class UnThemedLogDetails extends PureComponent<Props> {
links={links}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
onPinLine={onPinLine}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
getStats={() => calculateStats(row.dataFrame.fields[fieldIndex].values)}
displayedFields={displayedFields}
wrapLogMessage={wrapLogMessage}
@ -170,6 +177,8 @@ class UnThemedLogDetails extends PureComponent<Props> {
links={links}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
onPinLine={onPinLine}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
getStats={() => calculateStats(row.dataFrame.fields[fieldIndex].values)}
displayedFields={displayedFields}
wrapLogMessage={wrapLogMessage}

View File

@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/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 { createLogRow } from './__mocks__/logRow';
@ -147,4 +148,34 @@ describe('LogDetailsRow', () => {
// 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();
});
});
});

View File

@ -15,7 +15,7 @@ import {
LogRowModel,
} from '@grafana/data';
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';
@ -38,6 +38,8 @@ export interface Props extends Themeable2 {
row: LogRowModel;
app?: CoreApp;
isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
onPinLine?: (row: LogRowModel, allowUnPin?: boolean) => void;
pinLineButtonTooltipTitle?: PopoverContent;
}
interface State {
@ -263,6 +265,8 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
disableActions,
row,
app,
onPinLine,
pinLineButtonTooltipTitle,
} = this.props;
const { showFieldsStats, fieldStats, fieldCount } = this.state;
const styles = getStyles(theme);
@ -324,11 +328,32 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
{singleVal ? parsedValues[0] : this.generateMultiVal(parsedValues, true)}
{singleVal && this.generateClipboardButton(parsedValues[0])}
<div className={cx((singleVal || isMultiParsedValueWithNoContent) && styles.adjoiningLinkButton)}>
{links?.map((link, i) => (
<span key={`${link.title}-${i}`}>
<DataLinkButton link={link} />
</span>
))}
{links?.map((link, i) => {
if (link.onClick && onPinLine) {
const originalOnClick = link.onClick;
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>
</td>

View File

@ -1,24 +1,24 @@
import { cx } from '@emotion/css';
import { debounce } from 'lodash';
import memoizeOne from 'memoize-one';
import { PureComponent, MouseEvent } from 'react';
import * as React from 'react';
import { MouseEvent, PureComponent } from 'react';
import {
Field,
LinkModel,
LogRowModel,
LogsSortOrder,
dateTimeFormat,
CoreApp,
DataFrame,
dateTimeFormat,
Field,
LinkModel,
LogRowContextOptions,
LogRowModel,
LogsSortOrder,
} from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
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 { LogLabels } from './LogLabels';
@ -59,7 +59,7 @@ interface Props extends Themeable2 {
permalinkedRowId?: string;
scrollIntoView?: (element: HTMLElement) => void;
isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
onPinLine?: (row: LogRowModel) => void;
onPinLine?: (row: LogRowModel, allowUnPin?: boolean) => void;
onUnpinLine?: (row: LogRowModel) => void;
pinLineButtonTooltipTitle?: PopoverContent;
pinned?: boolean;
@ -219,14 +219,16 @@ class UnThemedLogRow extends PureComponent<Props, State> {
app,
styles,
getRowContextQuery,
pinned,
} = this.props;
const { showDetails, showingContext, permalinked } = this.state;
const levelStyles = getLogLevelStyles(theme, row.logLevel);
const { errorMessage, hasError } = checkLogsError(row);
const { sampleMessage, isSampled } = checkLogsSampled(row);
const logRowBackground = cx(styles.logsRow, {
[styles.errorLogRow]: hasError,
[styles.highlightBackground]: showingContext || permalinked,
[styles.highlightBackground]: showingContext || permalinked || pinned,
});
const logRowDetailsBackground = cx(styles.logsRow, {
[styles.errorLogRow]: hasError,
@ -327,6 +329,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
</tr>
{this.state.showDetails && (
<LogDetails
onPinLine={this.props.onPinLine}
className={logRowDetailsBackground}
showDuplicates={showDuplicates}
getFieldLinks={getFieldLinks}
@ -342,6 +345,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
app={app}
styles={styles}
isFilterLabelActive={this.props.isFilterLabelActive}
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
/>
)}
</>

View File

@ -48,7 +48,7 @@ export interface Props extends Themeable2 {
getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>;
onClickShowField?: (key: string) => void;
onClickHideField?: (key: string) => void;
onPinLine?: (row: LogRowModel) => void;
onPinLine?: (row: LogRowModel, allowUnPin?: boolean) => void;
onUnpinLine?: (row: LogRowModel) => void;
pinLineButtonTooltipTitle?: PopoverContent;
onLogRowHover?: (row?: LogRowModel) => void;
@ -63,6 +63,7 @@ export interface Props extends Themeable2 {
scrollIntoView?: (element: HTMLElement) => void;
isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
pinnedRowId?: string;
pinnedLogs?: string[];
containerRendered?: boolean;
/**
* 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() {
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 styles = getLogRowStyles(theme);
const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows;
@ -241,7 +243,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
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}
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined}
{...rest}
@ -264,7 +266,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine}
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}
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined}
{...rest}