New Logs Panel: Log line menu (#101060)

* Create LogLineMenu component

* Fine tune icon width

* LogLineMenu: Add placeholder options

* utils: create reusable handleOpenLogsContextClick

* LogLineMenu: add callbacks to menu items

* LogListContext: create component

* LogList: use log list context to connect menu callbacks

* LogLine: add pinned style

* Remove unused imports

* LogLine: add unit test

* LogLine: add menu test case

* LogLineMenu: add unit test

* LogLineMessage: add unit test

* LogListContext: add unit test

* Remove unused code

* Extract translations

* Fix handleOpenLogsContextClick

* Chore: memoize styles

* Virtualization: update node used for underflow detection

* Use useStyles2 instead of manually memoizing

* Virtualization: export getter instead of variable

* Open context: move stopPropagation to the old panel code

* Logs: add new container class
This commit is contained in:
Matias Chomicki
2025-02-27 18:34:02 +02:00
committed by GitHub
parent ef86582dfc
commit d78c646f93
17 changed files with 575 additions and 74 deletions
+12 -1
View File
@@ -1065,17 +1065,25 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
)}
{visualisationType === 'logs' && hasData && config.featureToggles.newLogsPanel && (
<>
<div data-testid="logRows" ref={logsContainerRef} className={styles.logRows}>
<div data-testid="logRows" ref={logsContainerRef} className={styles.logRowsWrapper}>
{logsContainerRef.current && (
<LogList
app={CoreApp.Explore}
logSupportsContext={showContextToggle}
containerElement={logsContainerRef.current}
displayedFields={displayedFields}
eventBus={eventBus}
forceEscape={forceEscape}
getFieldLinks={getFieldLinks}
getRowContextQuery={getRowContextQuery}
loadMore={loadMoreLogs}
logs={dedupedRows}
onOpenContext={onOpenContext}
onPermalinkClick={onPermalinkClick}
onPinLine={onPinToContentOutlineClick}
onUnpinLine={onPinToContentOutlineClick}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
pinnedLogs={pinnedLogs}
showTime={showTime}
sortOrder={logsSortOrder}
timeRange={props.range}
@@ -1185,6 +1193,9 @@ const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean, tableHeight: n
overflowY: 'visible',
width: '100%',
}),
logRowsWrapper: css({
width: '100%',
}),
visualisationType: css({
display: 'flex',
flex: '1',
@@ -10,10 +10,11 @@ import {
MouseEvent,
} from 'react';
import { LogRowContextOptions, LogRowModel, getDefaultTimeRange, locationUtil, urlUtil } from '@grafana/data';
import { LogRowContextOptions, LogRowModel } from '@grafana/data';
import { DataQuery } from '@grafana/schema';
import { ClipboardButton, IconButton, PopoverContent } from '@grafana/ui';
import { getConfig } from 'app/core/config';
import { handleOpenLogsContextClick } from '../utils';
import { LogRowStyles } from './getLogRowStyles';
@@ -35,7 +36,6 @@ interface Props {
styles: LogRowStyles;
mouseIsOver: boolean;
onBlur: () => void;
onPinToContentOutlineClick?: (row: LogRowModel, onOpenContext: (row: LogRowModel) => void) => void;
addonBefore?: ReactNode[];
addonAfter?: ReactNode[];
}
@@ -66,31 +66,9 @@ export const LogRowMenuCell = memo(
e.stopPropagation();
}, []);
const onShowContextClick = useCallback(
async (event: MouseEvent<HTMLButtonElement>) => {
async (event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
// if ctrl or meta key is pressed, open query in new Explore tab
if (
getRowContextQuery &&
(event.nativeEvent.ctrlKey || event.nativeEvent.metaKey || event.nativeEvent.shiftKey)
) {
const win = window.open('about:blank');
// for this request we don't want to use the cached filters from a context provider, but always want to refetch and clear
const query = await getRowContextQuery(row, undefined, false);
if (query && win) {
const url = urlUtil.renderUrl(locationUtil.assureBaseUrl(`${getConfig().appSubUrl}explore`), {
left: JSON.stringify({
datasource: query.datasource,
queries: [query],
range: getDefaultTimeRange(),
}),
});
win.location = url;
return;
}
win?.close();
}
onOpenContext(row);
handleOpenLogsContextClick(event, row, getRowContextQuery, onOpenContext);
},
[onOpenContext, getRowContextQuery, row]
);
@@ -1,4 +1,6 @@
import { FieldType, LogLevel, LogRowModel, toDataFrame } from '@grafana/data';
import { FieldType, LogLevel, LogRowModel, LogsSortOrder, toDataFrame } from '@grafana/data';
import { LogListModel, preProcessLogs, PreProcessOptions } from '../panel/processing';
export const createLogRow = (overrides?: Partial<LogRowModel>): LogRowModel => {
const uid = overrides?.uid || '1';
@@ -36,3 +38,16 @@ export const createLogRow = (overrides?: Partial<LogRowModel>): LogRowModel => {
...overrides,
};
};
export const createLogLine = (
overrides?: Partial<LogRowModel>,
processOptions: PreProcessOptions = {
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrap: false,
}
): LogListModel => {
const logs = preProcessLogs([createLogRow(overrides)], processOptions);
return logs[0];
};
@@ -4,7 +4,7 @@ import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window'
import { AbsoluteTimeRange, LogsSortOrder, TimeRange } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Spinner, useTheme2 } from '@grafana/ui';
import { Spinner, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { canScrollBottom, getVisibleRange, ScrollDirection, shouldLoadMore } from '../InfiniteScroll';
@@ -59,8 +59,7 @@ export const InfiniteScroll = ({
const lastEvent = useRef<Event | WheelEvent | null>(null);
const countRef = useRef(0);
const lastLogOfPage = useRef<string[]>([]);
const theme = useTheme2();
const styles = getStyles(theme);
const styles = useStyles2(getStyles);
useEffect(() => {
// Logs have not changed, ignore effect
@@ -0,0 +1,105 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createTheme } from '@grafana/data';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine } from '../__mocks__/logRow';
import { getStyles, LogLine } from './LogLine';
import { LogListModel } from './processing';
const theme = createTheme();
const styles = getStyles(theme);
describe('LogLine', () => {
let log: LogListModel;
beforeEach(() => {
log = createLogLine({ labels: { place: 'luna' } });
});
test('Renders a log line', () => {
render(
<LogLine
displayedFields={[]}
index={0}
log={log}
showTime={true}
style={{}}
styles={styles}
wrapLogMessage={false}
/>
);
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
expect(screen.getByText(log.body)).toBeInTheDocument();
});
test('Renders a log line with no timestamp', () => {
render(
<LogLine
displayedFields={[]}
index={0}
log={log}
showTime={false}
style={{}}
styles={styles}
wrapLogMessage={false}
/>
);
expect(screen.queryByText(log.timestamp)).not.toBeInTheDocument();
expect(screen.getByText(log.body)).toBeInTheDocument();
});
test('Renders a log line with displayed fields', () => {
render(
<LogLine
displayedFields={['place']}
index={0}
log={log}
showTime={true}
style={{}}
styles={styles}
wrapLogMessage={false}
/>
);
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
expect(screen.queryByText(log.body)).not.toBeInTheDocument();
expect(screen.getByText('luna')).toBeInTheDocument();
});
test('Renders a log line with body displayed fields', () => {
render(
<LogLine
displayedFields={['place', LOG_LINE_BODY_FIELD_NAME]}
index={0}
log={log}
showTime={true}
style={{}}
styles={styles}
wrapLogMessage={false}
/>
);
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
expect(screen.getByText(log.body)).toBeInTheDocument();
expect(screen.getByText('luna')).toBeInTheDocument();
});
describe('Log line menu', () => {
test('Renders a log line menu', async () => {
render(
<LogLine
displayedFields={[]}
index={0}
log={log}
showTime={true}
style={{}}
styles={styles}
wrapLogMessage={false}
/>
);
expect(screen.queryByText('Copy log line')).not.toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Log menu'));
expect(screen.getByText('Copy log line')).toBeInTheDocument();
});
});
});
@@ -1,12 +1,15 @@
import { css } from '@emotion/css';
import { CSSProperties, useEffect, useRef } from 'react';
import tinycolor from 'tinycolor2';
import { GrafanaTheme2 } from '@grafana/data';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogLineMenu } from './LogLineMenu';
import { useLogIsPinned } from './LogListContext';
import { LogFieldDimension, LogListModel } from './processing';
import { FIELD_GAP_MULTIPLIER, hasUnderOrOverflow } from './virtualization';
import { FIELD_GAP_MULTIPLIER, hasUnderOrOverflow, getLineHeight } from './virtualization';
interface Props {
displayedFields: string[];
@@ -32,6 +35,7 @@ export const LogLine = ({
wrapLogMessage,
}: Props) => {
const logLineRef = useRef<HTMLDivElement | null>(null);
const pinned = useLogIsPinned(log);
useEffect(() => {
if (!onOverflow || !logLineRef.current) {
@@ -45,7 +49,12 @@ export const LogLine = ({
}, [index, log.uid, onOverflow, style.height]);
return (
<div style={style} className={`${styles.logLine} ${variant ?? ''}`} ref={onOverflow ? logLineRef : undefined}>
<div
style={style}
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`}
ref={onOverflow ? logLineRef : undefined}
>
<LogLineMenu styles={styles} log={log} />
<div className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`}`}>
<Log displayedFields={displayedFields} log={log} showTime={showTime} styles={styles} />
</div>
@@ -57,7 +66,7 @@ interface LogProps {
displayedFields: string[];
log: LogListModel;
showTime: boolean;
styles: ReturnType<typeof getStyles>;
styles: LogLineStyles;
}
const Log = ({ displayedFields, log, showTime, styles }: LogProps) => {
@@ -67,7 +76,7 @@ const Log = ({ displayedFields, log, showTime, styles }: LogProps) => {
<span className={`${styles.level} level-${log.logLevel} field`}>{log.displayLevel}</span>
{displayedFields.length > 0 ? (
displayedFields.map((field) => (
<span className="field" title={field}>
<span className="field" title={field} key={field}>
{getDisplayedFieldValue(field, log)}
</span>
))
@@ -111,6 +120,9 @@ export const getStyles = (theme: GrafanaTheme2) => {
return {
logLine: css({
color: theme.colors.text.primary,
display: 'flex',
gap: theme.spacing(0.5),
flexDirection: 'row',
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.fontSize,
wordBreak: 'break-all',
@@ -129,6 +141,14 @@ export const getStyles = (theme: GrafanaTheme2) => {
},
},
}),
pinnedLogLine: css({
backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(),
}),
menuIcon: css({
height: getLineHeight(),
margin: 0,
padding: theme.spacing(0, 0, 0, 0.5),
}),
logLineMessage: css({
fontFamily: theme.typography.fontFamily,
textAlign: 'center',
@@ -0,0 +1,94 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createTheme } from '@grafana/data';
import { createLogLine } from '../__mocks__/logRow';
import { getStyles } from './LogLine';
import { LogLineMenu } from './LogLineMenu';
import { LogListContext } from './LogListContext';
import { LogListModel } from './processing';
const theme = createTheme();
const styles = getStyles(theme);
describe('LogLineMenu', () => {
let log: LogListModel;
beforeEach(() => {
log = createLogLine({ labels: { place: 'luna' }, rowId: '1' });
});
test('Renders the component', async () => {
render(<LogLineMenu log={log} styles={styles} />);
expect(screen.queryByText('Copy log line')).not.toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Log menu'));
expect(screen.getByText('Copy log line')).toBeInTheDocument();
});
describe('Options', () => {
test('Allows to copy a permalink', async () => {
const onPermalinkClick = jest.fn();
render(
<LogListContext.Provider value={{ onPermalinkClick }}>
<LogLineMenu log={log} styles={styles} />
</LogListContext.Provider>
);
await userEvent.click(screen.getByLabelText('Log menu'));
await userEvent.click(screen.getByText('Copy link to log line'));
expect(onPermalinkClick).toHaveBeenCalledTimes(1);
});
test('Allows to open show context', async () => {
const onOpenContext = jest.fn();
const logSupportsContext = jest.fn().mockReturnValue(true);
const getRowContextQuery = jest.fn();
render(
<LogListContext.Provider value={{ getRowContextQuery, logSupportsContext, onOpenContext }}>
<LogLineMenu log={log} styles={styles} />
</LogListContext.Provider>
);
await userEvent.click(screen.getByLabelText('Log menu'));
await userEvent.click(screen.getByText('Show context'));
expect(onOpenContext).toHaveBeenCalledTimes(1);
});
test('Uses logSupportsContext to control the display of show context', async () => {
const onOpenContext = jest.fn();
const logSupportsContext = jest.fn().mockReturnValue(false);
const getRowContextQuery = jest.fn();
render(
<LogListContext.Provider value={{ getRowContextQuery, logSupportsContext, onOpenContext }}>
<LogLineMenu log={log} styles={styles} />
</LogListContext.Provider>
);
await userEvent.click(screen.getByLabelText('Log menu'));
expect(screen.queryByText('Show context')).not.toBeInTheDocument();
});
test('Allows to pin log line', async () => {
const onPinLine = jest.fn();
render(
<LogListContext.Provider value={{ pinnedLogs: [], onPinLine }}>
<LogLineMenu log={log} styles={styles} />
</LogListContext.Provider>
);
await userEvent.click(screen.getByLabelText('Log menu'));
await userEvent.click(screen.getByText('Pin log'));
expect(onPinLine).toHaveBeenCalledTimes(1);
});
test('Allows to unpin log line', async () => {
const onUnpinLine = jest.fn();
render(
<LogListContext.Provider value={{ pinnedLogs: [log.uid], onUnpinLine }}>
<LogLineMenu log={log} styles={styles} />
</LogListContext.Provider>
);
await userEvent.click(screen.getByLabelText('Log menu'));
expect(screen.queryByText('Pin log')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Unpin log'));
expect(onUnpinLine).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,102 @@
import { useCallback, useMemo, useRef, MouseEvent } from 'react';
import { LogRowContextOptions, LogRowModel } from '@grafana/data';
import { DataQuery } from '@grafana/schema';
import { Dropdown, IconButton, Menu } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { copyText, handleOpenLogsContextClick } from '../../utils';
import { LogLineStyles } from './LogLine';
import { useLogIsPinned, useLogListContext } from './LogListContext';
import { LogListModel } from './processing';
export type GetRowContextQueryFn = (
row: LogRowModel,
options?: LogRowContextOptions,
cacheFilters?: boolean
) => Promise<DataQuery | null>;
interface Props {
log: LogListModel;
styles: LogLineStyles;
}
export const LogLineMenu = ({ log, styles }: Props) => {
const { getRowContextQuery, onOpenContext, onPermalinkClick, onPinLine, onUnpinLine, logSupportsContext } =
useLogListContext();
const pinned = useLogIsPinned(log);
const menuRef = useRef(null);
const copyLogLine = useCallback(() => {
copyText(log.entry, menuRef);
}, [log.entry]);
const copyLinkToLogLine = useCallback(() => {
onPermalinkClick?.(log);
}, [log, onPermalinkClick]);
const shouldlogSupportsContext = useMemo(
() => (logSupportsContext ? logSupportsContext(log) : false),
[log, logSupportsContext]
);
const showContext = useCallback(
async (event: MouseEvent<HTMLElement>) => {
handleOpenLogsContextClick(event, log, getRowContextQuery, (log: LogRowModel) => onOpenContext?.(log, () => {}));
},
[onOpenContext, getRowContextQuery, log]
);
const togglePinning = useCallback(() => {
if (pinned) {
onUnpinLine?.(log);
} else {
onPinLine?.(log);
}
}, [log, onPinLine, onUnpinLine, pinned]);
const menu = useCallback(
() => (
<Menu ref={menuRef}>
<Menu.Item onClick={copyLogLine} label={t('logs.log-line-menu.copy-log', 'Copy log line')} />
{onPermalinkClick && log.rowId !== undefined && log.uid && (
<Menu.Item onClick={copyLinkToLogLine} label={t('logs.log-line-menu.copy-link', 'Copy link to log line')} />
)}
{(shouldlogSupportsContext || onPinLine || onUnpinLine) && <Menu.Divider />}
{shouldlogSupportsContext && (
<Menu.Item onClick={showContext} label={t('logs.log-line-menu.show-context', 'Show context')} />
)}
{!pinned && onPinLine && (
<Menu.Item onClick={togglePinning} label={t('logs.log-line-menu.pin-to-outline', 'Pin log')} />
)}
{pinned && onUnpinLine && (
<Menu.Item onClick={togglePinning} label={t('logs.log-line-menu.unpin-from-outline', 'Unpin log')} />
)}
</Menu>
),
[
copyLinkToLogLine,
copyLogLine,
log.rowId,
log.uid,
onPermalinkClick,
onPinLine,
onUnpinLine,
pinned,
shouldlogSupportsContext,
showContext,
togglePinning,
]
);
return (
<Dropdown overlay={menu} placement="bottom-start">
<IconButton
className={styles.menuIcon}
name="ellipsis-v"
aria-label={t('logs.log-line-menu.icon-label', 'Log menu')}
/>
</Dropdown>
);
};
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createTheme } from '@grafana/data';
import { getStyles } from './LogLine';
import { LogLineMessage } from './LogLineMessage';
const theme = createTheme();
const styles = getStyles(theme);
describe('LogLineMessage', () => {
test('Renders a log line message', () => {
render(
<LogLineMessage style={{}} styles={styles}>
Message
</LogLineMessage>
);
expect(screen.getByText('Message')).toBeInTheDocument();
});
test('Renders a button with the message', async () => {
const handleClick = jest.fn();
render(
<LogLineMessage style={{}} styles={styles} onClick={handleClick}>
Message
</LogLineMessage>
);
await userEvent.click(screen.getByText('Message'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
@@ -14,10 +14,12 @@ import {
LogsSortOrder,
TimeRange,
} from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { PopoverContent, useTheme2 } from '@grafana/ui';
import { InfiniteScroll } from './InfiniteScroll';
import { getGridTemplateColumns } from './LogLine';
import { GetRowContextQueryFn } from './LogLineMenu';
import { LogListContext } from './LogListContext';
import { preProcessLogs, LogListModel, calculateFieldDimensions, LogFieldDimension } from './processing';
import {
getLogLineSize,
@@ -36,9 +38,17 @@ interface Props {
eventBus: EventBus;
forceEscape?: boolean;
getFieldLinks?: GetFieldLinksFn;
getRowContextQuery?: GetRowContextQueryFn;
initialScrollPosition?: 'top' | 'bottom';
loadMore?: (range: AbsoluteTimeRange) => void;
logs: LogRowModel[];
logSupportsContext?: (row: LogRowModel) => boolean;
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
onPinLine?: (row: LogRowModel) => void;
onOpenContext?: (row: LogRowModel, onClose: () => void) => void;
onUnpinLine?: (row: LogRowModel) => void;
pinLineButtonTooltipTitle?: PopoverContent;
pinnedLogs?: string[];
showTime: boolean;
sortOrder: LogsSortOrder;
timeRange: TimeRange;
@@ -61,6 +71,7 @@ export const LogList = ({
timeRange,
timeZone,
wrapLogMessage,
...logListContext
}: Props) => {
const [processedLogs, setProcessedLogs] = useState<LogListModel[]>([]);
const [listHeight, setListHeight] = useState(
@@ -134,40 +145,42 @@ export const LogList = ({
}
return (
<InfiniteScroll
displayedFields={displayedFields}
handleOverflow={handleOverflow}
logs={processedLogs}
loadMore={loadMore}
scrollElement={scrollRef.current}
showTime={showTime}
sortOrder={sortOrder}
timeRange={timeRange}
timeZone={timeZone}
setInitialScrollPosition={handleScrollPosition}
wrapLogMessage={wrapLogMessage}
>
{({ getItemKey, itemCount, onItemsRendered, Renderer }) => (
<VariableSizeList
className={styles.logList}
height={listHeight}
itemCount={itemCount}
itemSize={getLogLineSize.bind(null, processedLogs, containerElement, displayedFields, {
wrap: wrapLogMessage,
showTime,
})}
itemKey={getItemKey}
layout="vertical"
onItemsRendered={onItemsRendered}
outerRef={scrollRef}
ref={listRef}
style={{ overflowY: 'scroll' }}
width="100%"
>
{Renderer}
</VariableSizeList>
)}
</InfiniteScroll>
<LogListContext.Provider value={logListContext}>
<InfiniteScroll
displayedFields={displayedFields}
handleOverflow={handleOverflow}
logs={processedLogs}
loadMore={loadMore}
scrollElement={scrollRef.current}
showTime={showTime}
sortOrder={sortOrder}
timeRange={timeRange}
timeZone={timeZone}
setInitialScrollPosition={handleScrollPosition}
wrapLogMessage={wrapLogMessage}
>
{({ getItemKey, itemCount, onItemsRendered, Renderer }) => (
<VariableSizeList
className={styles.logList}
height={listHeight}
itemCount={itemCount}
itemSize={getLogLineSize.bind(null, processedLogs, containerElement, displayedFields, {
wrap: wrapLogMessage,
showTime,
})}
itemKey={getItemKey}
layout="vertical"
onItemsRendered={onItemsRendered}
outerRef={scrollRef}
ref={listRef}
style={{ overflowY: 'scroll' }}
width="100%"
>
{Renderer}
</VariableSizeList>
)}
</InfiniteScroll>
</LogListContext.Provider>
);
};
@@ -0,0 +1,46 @@
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { createLogLine } from '../__mocks__/logRow';
import { useLogListContextData, useLogListContext, useLogIsPinned, LogListContext } from './LogListContext';
const log = createLogLine({ rowId: 'yep' });
const value = {
getRowContextQuery: jest.fn(),
logSupportsContext: jest.fn(),
onPermalinkClick: jest.fn(),
onPinLine: jest.fn(),
onOpenContext: jest.fn(),
onUnpinLine: jest.fn(),
pinLineButtonTooltipTitle: 'test',
pinnedLogs: ['yep'],
};
const wrapper = ({ children }: { children: ReactNode }) => (
<LogListContext.Provider value={value}>{children}</LogListContext.Provider>
);
test('Provides the Log List Context data', () => {
const { result } = renderHook(() => useLogListContext(), { wrapper });
expect(result.current).toEqual(value);
});
test('Allows to access context attributes', () => {
const { result } = renderHook(() => useLogListContextData('pinnedLogs'), { wrapper });
expect(result.current).toEqual(value.pinnedLogs);
});
test('Allows to tell if a log is pinned', () => {
const { result } = renderHook(() => useLogIsPinned(log), { wrapper });
expect(result.current).toBe(true);
});
test('Allows to tell if a log is pinned', () => {
const otherLog = createLogLine({ rowId: 'nope' });
const { result } = renderHook(() => useLogIsPinned(otherLog), { wrapper });
expect(result.current).toBe(false);
});
@@ -0,0 +1,33 @@
import { createContext, useContext } from 'react';
import { LogRowModel } from '@grafana/data';
import { PopoverContent } from '@grafana/ui';
import { GetRowContextQueryFn } from './LogLineMenu';
export interface LogListContextData {
getRowContextQuery?: GetRowContextQueryFn;
logSupportsContext?: (row: LogRowModel) => boolean;
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
onPinLine?: (row: LogRowModel) => void;
onOpenContext?: (row: LogRowModel, onClose: () => void) => void;
onUnpinLine?: (row: LogRowModel) => void;
pinLineButtonTooltipTitle?: PopoverContent;
pinnedLogs?: string[];
}
export const LogListContext = createContext<LogListContextData>({});
export const useLogListContextData = (key: keyof LogListContextData) => {
const data: LogListContextData = useContext(LogListContext);
return data[key];
};
export const useLogListContext = (): LogListContextData => {
return useContext(LogListContext);
};
export const useLogIsPinned = (log: LogRowModel) => {
const { pinnedLogs } = useContext(LogListContext);
return pinnedLogs?.some((logId) => logId === log.rowId);
};
@@ -20,7 +20,7 @@ export interface LogFieldDimension {
width: number;
}
interface PreProcessOptions {
export interface PreProcessOptions {
escape: boolean;
getFieldLinks?: GetFieldLinksFn;
order: LogsSortOrder;
@@ -8,10 +8,13 @@ let gridSize = 8;
let paddingBottom = gridSize * 0.75;
let lineHeight = 22;
let measurementMode: 'canvas' | 'dom' = 'canvas';
const iconWidth = 24;
// Controls the space between fields in the log line, timestamp, level, displayed fields, and log line body
export const FIELD_GAP_MULTIPLIER = 1.5;
export const getLineHeight = () => lineHeight;
export function init(theme: GrafanaTheme2) {
const font = `${theme.typography.fontSize}px ${theme.typography.fontFamilyMonospace}`;
const letterSpacing = theme.typography.body.letterSpacing;
@@ -193,7 +196,7 @@ export function hasUnderOrOverflow(element: HTMLDivElement, calculatedHeight?: n
if (element.scrollHeight > height) {
return element.scrollHeight;
}
const child = element.firstChild;
const child = element.children[1];
if (child instanceof HTMLDivElement && child.clientHeight < height) {
return child.clientHeight;
}
@@ -203,7 +206,7 @@ export function hasUnderOrOverflow(element: HTMLDivElement, calculatedHeight?: n
const scrollBarWidth = getScrollbarWidth();
export function getLogContainerWidth(container: HTMLDivElement) {
return container.clientWidth - scrollBarWidth;
return container.clientWidth - scrollBarWidth - iconWidth;
}
export function getScrollbarWidth() {
+34
View File
@@ -1,4 +1,5 @@
import { countBy, chain } from 'lodash';
import { MouseEvent } from 'react';
import {
LogLevel,
@@ -15,9 +16,14 @@ import {
LogsVolumeType,
NumericLogLevel,
getFieldDisplayName,
getDefaultTimeRange,
locationUtil,
urlUtil,
} from '@grafana/data';
import { getConfig } from 'app/core/config';
import { getDataframeFields } from './components/logParser';
import { GetRowContextQueryFn } from './components/panel/LogLineMenu';
/**
* Returns the log level of a log line.
@@ -303,6 +309,34 @@ export const copyText = async (text: string, buttonRef: React.MutableRefObject<E
}
};
export async function handleOpenLogsContextClick(
event: MouseEvent<HTMLElement>,
row: LogRowModel,
getRowContextQuery: GetRowContextQueryFn | undefined,
onOpenContext: (row: LogRowModel) => void
) {
// if ctrl or meta key is pressed, open query in new Explore tab
if (getRowContextQuery && (event.nativeEvent.ctrlKey || event.nativeEvent.metaKey || event.nativeEvent.shiftKey)) {
const win = window.open('about:blank');
// for this request we don't want to use the cached filters from a context provider, but always want to refetch and clear
const query = await getRowContextQuery(row, undefined, false);
if (query && win) {
const url = urlUtil.renderUrl(locationUtil.assureBaseUrl(`${getConfig().appSubUrl}explore`), {
left: JSON.stringify({
datasource: query.datasource,
queries: [query],
range: getDefaultTimeRange(),
}),
});
win.location = url;
return;
}
win?.close();
}
onOpenContext(row);
}
export function getLogLevelInfo(dataFrame: DataFrame, allDataFrames: DataFrame[]) {
const fieldCache = new FieldCache(dataFrame);
const timeField = fieldCache.getFirstFieldOfType(FieldType.time);
+8
View File
@@ -2257,6 +2257,14 @@
"log-line": "Log line",
"no-details": "No details available"
},
"log-line-menu": {
"copy-link": "Copy link to log line",
"copy-log": "Copy log line",
"icon-label": "Log menu",
"pin-to-outline": "Pin log",
"show-context": "Show context",
"unpin-from-outline": "Unpin log"
},
"log-row-message": {
"ellipsis": "… ",
"more": "more",
@@ -2257,6 +2257,14 @@
"log-line": "Ŀőģ ľįʼnę",
"no-details": "Ńő đęŧäįľş äväįľäþľę"
},
"log-line-menu": {
"copy-link": "Cőpy ľįʼnĸ ŧő ľőģ ľįʼnę",
"copy-log": "Cőpy ľőģ ľįʼnę",
"icon-label": "Ŀőģ męʼnū",
"pin-to-outline": "Pįʼn ľőģ",
"show-context": "Ŝĥőŵ čőʼnŧęχŧ",
"unpin-from-outline": "Ůʼnpįʼn ľőģ"
},
"log-row-message": {
"ellipsis": "… ",
"more": "mőřę",