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:
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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őřę",
|
||||
|
||||
Reference in New Issue
Block a user