New Logs Panel: Add Log Details support (#105609)
* Log list: add onclick listener * LogListContext: add basic details support * LogLineDetails: create component * Address lint issues * Log Details: make resizable and store size * LogListModel: add sampled and error support * LogDetails: pass more required props * LogLineContext: add interactive callbacks support * LogLineDetails: pass interactive callbacks * LogList: pass displayedFields callbacks * LogLine: move click listener * LogLineMenu: support showing details * LogLine: move onclick listener * LogListContext: remove displayedFields intermediation * i18n * LogListContext: abstract details shown function * LogLine: visually show expanded lines * LogDetails: remove min width for labels * LogLineDetails: add close button * LogList: add extra wrapper to get width * LogLineDetails: update logs size on resize * virtualization: update to new width reference * LogLine: check overflow on every re-render * LogList: debug virtualization when resizing * LogLineDetails: make it scrollable * LogListContext: make detailsWidth not undefined * Update tests with new attributes * LogLine: update collapsed state with container changes * LogLine: move cursor property to clickable styles * LogList: fix height recalculation when display options change * Logs: fix feature toggles support * Logs: more feature toggles adjustments * Lint * LogLine: support duplicates, hasError, and isSampled * Logs: debug feature flag combinations * i18n * Prettier * New Logs Panel: generate storage key for dashboards * Explore Logs: fix filtered levels * Logs Sample: integrate new panel * LogLine: fix unwrapped logs * Fix test * Update test * Logs panel: update test * Prettier * LogLine: update tests * LogLineMenu: update test * LogList: update unit test * processing: update test * virtualization: update unit test
This commit is contained in:
+1
@@ -13,6 +13,7 @@ import * as common from '@grafana/schema';
|
||||
export const pluginVersion = "12.1.0-pre";
|
||||
|
||||
export interface Options {
|
||||
controlsStorageKey?: string;
|
||||
dedupStrategy: common.LogsDedupStrategy;
|
||||
enableInfiniteScrolling?: boolean;
|
||||
enableLogDetails: boolean;
|
||||
|
||||
@@ -466,7 +466,15 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
}
|
||||
|
||||
renderLogsSamplePanel() {
|
||||
const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance, queries } = this.props;
|
||||
const {
|
||||
logsSample,
|
||||
timeZone,
|
||||
setSupplementaryQueryEnabled,
|
||||
exploreId,
|
||||
datasourceInstance,
|
||||
queries,
|
||||
queryResponse,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ContentOutlineItem
|
||||
@@ -484,6 +492,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
setLogsSampleEnabled={(enabled: boolean) =>
|
||||
setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsSample)
|
||||
}
|
||||
timeRange={queryResponse.timeRange}
|
||||
/>
|
||||
</ContentOutlineItem>
|
||||
);
|
||||
|
||||
@@ -781,6 +781,10 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
return;
|
||||
}
|
||||
const allLevels = logLevelsRef.current ?? Object.keys(LogLevelColor).map(getLogLevelFromKey);
|
||||
if (!allLevels.length) {
|
||||
// Logs panel is disabled
|
||||
return;
|
||||
}
|
||||
if (hiddenLogLevels.length === 0) {
|
||||
toggleLegendRef.current?.(value[0], SeriesVisibilityChangeMode.ToggleSelection);
|
||||
setHiddenLogLevels(allLevels.filter((level) => level !== value[0]));
|
||||
@@ -805,7 +809,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
|
||||
const filterLevels: LogLevel[] | undefined = useMemo(
|
||||
() =>
|
||||
!logLevelsRef.current
|
||||
!logLevelsRef.current || logLevelsRef.current.length === 0
|
||||
? undefined
|
||||
: logLevelsRef.current.filter((level) => hiddenLogLevels.length > 0 && !hiddenLogLevels.includes(level)),
|
||||
[hiddenLogLevels]
|
||||
@@ -1020,57 +1024,59 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!config.featureToggles.newLogsPanel && config.featureToggles.logsPanelControls && hasData && (
|
||||
<div className={styles.logRowsWrapper} data-testid="logRows">
|
||||
<ControlledLogRows
|
||||
logsTableFrames={props.logsFrames}
|
||||
width={width}
|
||||
updatePanelState={updatePanelState}
|
||||
panelState={panelState?.logs}
|
||||
datasourceType={props.datasourceType}
|
||||
splitOpen={splitOpen}
|
||||
visualisationType={visualisationType}
|
||||
loading={loading}
|
||||
loadMoreLogs={infiniteScrollAvailable ? loadMoreLogs : undefined}
|
||||
range={props.range}
|
||||
pinnedLogs={pinnedLogs}
|
||||
logRows={logRows}
|
||||
deduplicatedRows={dedupedRows}
|
||||
dedupStrategy={dedupStrategy}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
showContextToggle={showContextToggle}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
showLabels={showLabels}
|
||||
showTime={showTime}
|
||||
enableLogDetails={true}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
prettifyLogMessage={prettifyLogMessage}
|
||||
timeZone={timeZone}
|
||||
getFieldLinks={getFieldLinks}
|
||||
logsSortOrder={logsSortOrder}
|
||||
displayedFields={displayedFields}
|
||||
onClickShowField={showField}
|
||||
onClickHideField={hideField}
|
||||
app={CoreApp.Explore}
|
||||
onLogRowHover={onLogRowHover}
|
||||
onOpenContext={onOpenContext}
|
||||
onPermalinkClick={onPermalinkClick}
|
||||
permalinkedRowId={panelState?.logs?.id}
|
||||
scrollIntoView={scrollIntoView}
|
||||
isFilterLabelActive={props.isFilterLabelActive}
|
||||
onClickFilterString={props.onClickFilterString}
|
||||
onClickFilterOutString={props.onClickFilterOutString}
|
||||
onUnpinLine={onPinToContentOutlineClick}
|
||||
onPinLine={onPinToContentOutlineClick}
|
||||
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
|
||||
logsMeta={logsMeta}
|
||||
logOptionsStorageKey={SETTING_KEY_ROOT}
|
||||
onLogOptionsChange={onLogOptionsChange}
|
||||
hasUnescapedContent={hasUnescapedContent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(!config.featureToggles.newLogsPanel || visualisationType === 'table') &&
|
||||
config.featureToggles.logsPanelControls &&
|
||||
hasData && (
|
||||
<div className={styles.logRowsWrapper} data-testid="logRows">
|
||||
<ControlledLogRows
|
||||
logsTableFrames={props.logsFrames}
|
||||
width={width}
|
||||
updatePanelState={updatePanelState}
|
||||
panelState={panelState?.logs}
|
||||
datasourceType={props.datasourceType}
|
||||
splitOpen={splitOpen}
|
||||
visualisationType={visualisationType}
|
||||
loading={loading}
|
||||
loadMoreLogs={infiniteScrollAvailable ? loadMoreLogs : undefined}
|
||||
range={props.range}
|
||||
pinnedLogs={pinnedLogs}
|
||||
logRows={logRows}
|
||||
deduplicatedRows={dedupedRows}
|
||||
dedupStrategy={dedupStrategy}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
showContextToggle={showContextToggle}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
showLabels={showLabels}
|
||||
showTime={showTime}
|
||||
enableLogDetails={true}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
prettifyLogMessage={prettifyLogMessage}
|
||||
timeZone={timeZone}
|
||||
getFieldLinks={getFieldLinks}
|
||||
logsSortOrder={logsSortOrder}
|
||||
displayedFields={displayedFields}
|
||||
onClickShowField={showField}
|
||||
onClickHideField={hideField}
|
||||
app={CoreApp.Explore}
|
||||
onLogRowHover={onLogRowHover}
|
||||
onOpenContext={onOpenContext}
|
||||
onPermalinkClick={onPermalinkClick}
|
||||
permalinkedRowId={panelState?.logs?.id}
|
||||
scrollIntoView={scrollIntoView}
|
||||
isFilterLabelActive={props.isFilterLabelActive}
|
||||
onClickFilterString={props.onClickFilterString}
|
||||
onClickFilterOutString={props.onClickFilterOutString}
|
||||
onUnpinLine={onPinToContentOutlineClick}
|
||||
onPinLine={onPinToContentOutlineClick}
|
||||
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
|
||||
logsMeta={logsMeta}
|
||||
logOptionsStorageKey={SETTING_KEY_ROOT}
|
||||
onLogOptionsChange={onLogOptionsChange}
|
||||
hasUnescapedContent={hasUnescapedContent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!config.featureToggles.logsPanelControls &&
|
||||
!config.featureToggles.newLogsPanel &&
|
||||
visualisationType === 'logs' &&
|
||||
@@ -1148,17 +1154,25 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
<LogList
|
||||
app={CoreApp.Explore}
|
||||
containerElement={logsContainerRef.current}
|
||||
enableLogDetails={true}
|
||||
dedupStrategy={dedupStrategy}
|
||||
displayedFields={displayedFields}
|
||||
filterLevels={filterLevels}
|
||||
getFieldLinks={getFieldLinks}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
isLabelFilterActive={props.isFilterLabelActive}
|
||||
loading={loading}
|
||||
loadMore={loadMoreLogs}
|
||||
logOptionsStorageKey={SETTING_KEY_ROOT}
|
||||
logs={dedupedRows}
|
||||
logsMeta={logsMeta}
|
||||
logSupportsContext={showContextToggle}
|
||||
onClickShowField={showField}
|
||||
onClickHideField={hideField}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
onClickFilterString={props.onClickFilterString}
|
||||
onClickFilterOutString={props.onClickFilterOutString}
|
||||
onLogOptionsChange={onLogOptionsChange}
|
||||
onLogLineHover={onLogRowHover}
|
||||
onOpenContext={onOpenContext}
|
||||
|
||||
@@ -108,13 +108,15 @@ export const LogsMetaRow = memo(
|
||||
};
|
||||
})}
|
||||
/>
|
||||
{!config.featureToggles.logsPanelControls && !config.exploreHideLogsDownload && (
|
||||
<Dropdown overlay={downloadMenu}>
|
||||
<ToolbarButton isOpen={false} variant="canvas" icon="download-alt">
|
||||
<Trans i18nKey="explore.logs-meta-row.download">Download</Trans>
|
||||
</ToolbarButton>
|
||||
</Dropdown>
|
||||
)}
|
||||
{!config.featureToggles.logsPanelControls &&
|
||||
!config.featureToggles.newLogsPanel &&
|
||||
!config.exploreHideLogsDownload && (
|
||||
<Dropdown overlay={downloadMenu}>
|
||||
<ToolbarButton isOpen={false} variant="canvas" icon="download-alt">
|
||||
<Trans i18nKey="explore.logs-meta-row.download">Download</Trans>
|
||||
</ToolbarButton>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,14 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import { FieldType, LoadingState, SupplementaryQueryType, DataSourceApi, createDataFrame } from '@grafana/data';
|
||||
import {
|
||||
FieldType,
|
||||
LoadingState,
|
||||
SupplementaryQueryType,
|
||||
DataSourceApi,
|
||||
createDataFrame,
|
||||
getDefaultTimeRange,
|
||||
} from '@grafana/data';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
|
||||
import { LogsSamplePanel } from './LogsSamplePanel';
|
||||
@@ -23,6 +30,7 @@ const createProps = (propOverrides?: Partial<ComponentProps<typeof LogsSamplePan
|
||||
setLogsSampleEnabled: jest.fn(),
|
||||
queries: [],
|
||||
splitOpen: jest.fn(),
|
||||
timeRange: getDefaultTimeRange(),
|
||||
};
|
||||
|
||||
return { ...props, ...propOverrides };
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import {
|
||||
CoreApp,
|
||||
DataQueryResponse,
|
||||
DataSourceApi,
|
||||
GrafanaTheme2,
|
||||
@@ -9,12 +11,14 @@ import {
|
||||
LogsDedupStrategy,
|
||||
SplitOpen,
|
||||
SupplementaryQueryType,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import { Trans, useTranslate } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { DataQuery, TimeZone } from '@grafana/schema';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { DataQuery, LogsSortOrder, TimeZone } from '@grafana/schema';
|
||||
import { Button, Collapse, Icon, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import store from 'app/core/store';
|
||||
import { LogList } from 'app/features/logs/components/panel/LogList';
|
||||
|
||||
import { LogRows } from '../../logs/components/LogRows';
|
||||
import { dataFrameToLogsModel } from '../../logs/logsModel';
|
||||
@@ -30,6 +34,7 @@ type Props = {
|
||||
datasourceInstance: DataSourceApi | null | undefined;
|
||||
splitOpen: SplitOpen;
|
||||
setLogsSampleEnabled: (enabled: boolean) => void;
|
||||
timeRange: TimeRange;
|
||||
};
|
||||
|
||||
export function LogsSamplePanel(props: Props) {
|
||||
@@ -37,6 +42,8 @@ export function LogsSamplePanel(props: Props) {
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const { t } = useTranslate();
|
||||
const logsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const onToggleLogsSampleCollapse = (isOpen: boolean) => {
|
||||
setLogsSampleEnabled(isOpen);
|
||||
reportInteraction('grafana_explore_logs_sample_toggle_clicked', {
|
||||
@@ -103,24 +110,35 @@ export function LogsSamplePanel(props: Props) {
|
||||
);
|
||||
} else {
|
||||
const logs = dataFrameToLogsModel(queryResponse.data);
|
||||
LogsSamplePanelContent = (
|
||||
<>
|
||||
<OpenInSplitViewButton />
|
||||
<div className={styles.logContainer}>
|
||||
<LogRows
|
||||
logRows={logs.rows}
|
||||
dedupStrategy={LogsDedupStrategy.none}
|
||||
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
|
||||
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
|
||||
wrapLogMessage={store.getBool(SETTINGS_KEYS.wrapLogMessage, true)}
|
||||
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
|
||||
timeZone={timeZone}
|
||||
enableLogDetails={true}
|
||||
scrollElement={null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
LogsSamplePanelContent =
|
||||
config.featureToggles.newLogsPanel && logsContainerRef.current ? (
|
||||
<LogList
|
||||
app={CoreApp.Explore}
|
||||
containerElement={logsContainerRef.current}
|
||||
enableLogDetails
|
||||
dedupStrategy={LogsDedupStrategy.none}
|
||||
displayedFields={[]}
|
||||
logs={logs.rows}
|
||||
showControls={false}
|
||||
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
|
||||
sortOrder={store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending}
|
||||
timeRange={props.timeRange}
|
||||
timeZone={timeZone}
|
||||
wrapLogMessage={store.getBool(SETTINGS_KEYS.wrapLogMessage, true)}
|
||||
/>
|
||||
) : (
|
||||
<LogRows
|
||||
logRows={logs.rows}
|
||||
dedupStrategy={LogsDedupStrategy.none}
|
||||
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
|
||||
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
|
||||
wrapLogMessage={store.getBool(SETTINGS_KEYS.wrapLogMessage, true)}
|
||||
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
|
||||
timeZone={timeZone}
|
||||
enableLogDetails
|
||||
scrollElement={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return queryResponse?.state !== LoadingState.NotStarted ? (
|
||||
@@ -139,7 +157,10 @@ export function LogsSamplePanel(props: Props) {
|
||||
collapsible={true}
|
||||
onToggle={onToggleLogsSampleCollapse}
|
||||
>
|
||||
{LogsSamplePanelContent}
|
||||
<OpenInSplitViewButton />
|
||||
<div className={styles.logContainer} ref={logsContainerRef}>
|
||||
{LogsSamplePanelContent}
|
||||
</div>
|
||||
</Collapse>
|
||||
) : null;
|
||||
}
|
||||
@@ -152,7 +173,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
right: theme.spacing(1),
|
||||
}),
|
||||
logContainer: css({
|
||||
overflow: 'scroll',
|
||||
overflow: config.featureToggles.newLogsPanel ? 'visible' : 'scroll',
|
||||
}),
|
||||
infoTooltip: css({
|
||||
marginLeft: theme.spacing(1),
|
||||
|
||||
@@ -71,6 +71,7 @@ export const ControlledLogRows = forwardRef<HTMLDivElement | null, ControlledLog
|
||||
app={rest.app || CoreApp.Unknown}
|
||||
displayedFields={[]}
|
||||
dedupStrategy={dedupStrategy}
|
||||
enableLogDetails={false}
|
||||
hasUnescapedContent={hasUnescapedContent}
|
||||
logOptionsStorageKey={logOptionsStorageKey}
|
||||
logs={deduplicatedRows ?? []}
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface Props extends Themeable2 {
|
||||
|
||||
onPinLine?: (row: LogRowModel) => void;
|
||||
pinLineButtonTooltipTitle?: PopoverContent;
|
||||
mode?: 'inline' | 'sidebar';
|
||||
}
|
||||
|
||||
class UnThemedLogDetails extends PureComponent<Props> {
|
||||
@@ -56,6 +57,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||
onPinLine,
|
||||
styles,
|
||||
pinLineButtonTooltipTitle,
|
||||
mode = 'inline',
|
||||
} = this.props;
|
||||
const levelStyles = getLogLevelStyles(theme, row.logLevel);
|
||||
const labels = row.labels ? row.labels : {};
|
||||
@@ -85,9 +87,14 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||
return (
|
||||
<tr className={cx(className, styles.logDetails)}>
|
||||
{showDuplicates && <td />}
|
||||
<td className={levelClassName} aria-label={t('logs.un-themed-log-details.aria-label-log-level', 'Log level')} />
|
||||
{mode === 'inline' && (
|
||||
<td
|
||||
className={levelClassName}
|
||||
aria-label={t('logs.un-themed-log-details.aria-label-log-level', 'Log level')}
|
||||
/>
|
||||
)}
|
||||
<td colSpan={4}>
|
||||
<div className={styles.logDetailsContainer}>
|
||||
<div className={mode === 'inline' ? styles.logDetailsContainer : styles.logDetailsSidebarContainer}>
|
||||
<table className={styles.logDetailsTable}>
|
||||
<tbody>
|
||||
{displayedFields && displayedFields.length > 0 && (
|
||||
|
||||
@@ -187,6 +187,14 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
|
||||
margin: theme.spacing(2.5, 1, 2.5, 2),
|
||||
cursor: 'default',
|
||||
}),
|
||||
logDetailsSidebarContainer: css({
|
||||
label: 'logs-row-details-table',
|
||||
border: `1px solid ${theme.colors.border.medium}`,
|
||||
padding: theme.spacing(0, 1, 1),
|
||||
borderRadius: theme.shape.radius.default,
|
||||
margin: theme.spacing(0, 1, 0, 1),
|
||||
cursor: 'default',
|
||||
}),
|
||||
logDetailsTable: css({
|
||||
label: 'logs-row-details-table',
|
||||
lineHeight: '18px',
|
||||
@@ -206,7 +214,6 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
|
||||
logDetailsLabel: css({
|
||||
label: 'logs-row-details__label',
|
||||
maxWidth: '30em',
|
||||
minWidth: '20em',
|
||||
padding: theme.spacing(0, 1),
|
||||
overflowWrap: 'break-word',
|
||||
}),
|
||||
|
||||
@@ -27,6 +27,7 @@ interface Props {
|
||||
handleOverflow: (index: number, id: string, height?: number) => void;
|
||||
loadMore?: (range: AbsoluteTimeRange) => void;
|
||||
logs: LogListModel[];
|
||||
onClick: (log: LogListModel) => void;
|
||||
scrollElement: HTMLDivElement | null;
|
||||
setInitialScrollPosition: () => void;
|
||||
showTime: boolean;
|
||||
@@ -44,6 +45,7 @@ export const InfiniteScroll = ({
|
||||
handleOverflow,
|
||||
loadMore,
|
||||
logs,
|
||||
onClick,
|
||||
scrollElement,
|
||||
setInitialScrollPosition,
|
||||
showTime,
|
||||
@@ -152,6 +154,7 @@ export const InfiniteScroll = ({
|
||||
displayedFields={displayedFields}
|
||||
index={index}
|
||||
log={logs[index]}
|
||||
onClick={onClick}
|
||||
showTime={showTime}
|
||||
style={style}
|
||||
styles={styles}
|
||||
@@ -166,6 +169,7 @@ export const InfiniteScroll = ({
|
||||
handleOverflow,
|
||||
infiniteLoaderState,
|
||||
logs,
|
||||
onClick,
|
||||
onLoadMore,
|
||||
showTime,
|
||||
sortOrder,
|
||||
|
||||
@@ -6,8 +6,9 @@ import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { createLogLine } from '../__mocks__/logRow';
|
||||
|
||||
import { getStyles, LogLine } from './LogLine';
|
||||
import { getStyles, LogLine, Props } from './LogLine';
|
||||
import { LogListContextProvider } from './LogListContext';
|
||||
import { defaultProps } from './__mocks__/LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
import { getTruncationLength } from './virtualization';
|
||||
|
||||
@@ -17,6 +18,7 @@ jest.mock('./virtualization');
|
||||
const theme = createTheme();
|
||||
const styles = getStyles(theme);
|
||||
const contextProps = {
|
||||
...defaultProps,
|
||||
app: CoreApp.Unknown,
|
||||
dedupStrategy: LogsDedupStrategy.exact,
|
||||
displayedFields: [],
|
||||
@@ -28,72 +30,42 @@ const contextProps = {
|
||||
};
|
||||
|
||||
describe('LogLine', () => {
|
||||
let log: LogListModel;
|
||||
let log: LogListModel, defaultProps: Props;
|
||||
beforeEach(() => {
|
||||
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` });
|
||||
defaultProps = {
|
||||
displayedFields: [],
|
||||
index: 0,
|
||||
log,
|
||||
onClick: jest.fn(),
|
||||
showTime: true,
|
||||
style: {},
|
||||
styles: styles,
|
||||
wrapLogMessage: true,
|
||||
};
|
||||
});
|
||||
|
||||
test('Renders a log line', () => {
|
||||
render(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
);
|
||||
render(<LogLine {...defaultProps} />);
|
||||
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
|
||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Renders a log line with no timestamp', () => {
|
||||
render(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={false}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
);
|
||||
render(<LogLine {...defaultProps} showTime={false} />);
|
||||
expect(screen.queryByText(log.timestamp)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Renders a log line with displayed fields', () => {
|
||||
render(
|
||||
<LogLine
|
||||
displayedFields={['place']}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
);
|
||||
render(<LogLine {...defaultProps} displayedFields={['place']} />);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
render(<LogLine {...defaultProps} displayedFields={['place', LOG_LINE_BODY_FIELD_NAME]} />);
|
||||
expect(screen.getByText(log.timestamp)).toBeInTheDocument();
|
||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('luna')).toBeInTheDocument();
|
||||
@@ -103,34 +75,73 @@ describe('LogLine', () => {
|
||||
const onLogLineHover = jest.fn();
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} onLogLineHover={onLogLineHover}>
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
<LogLine {...defaultProps} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.hover(screen.getByText('log message 1'));
|
||||
expect(onLogLineHover).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Listens to on click events', async () => {
|
||||
const onClick = jest.fn();
|
||||
render(
|
||||
<LogListContextProvider {...contextProps}>
|
||||
<LogLine {...defaultProps} onClick={onClick} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByText('log message 1'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Shows the deduplication count', async () => {
|
||||
log.duplicates = 1;
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} dedupStrategy={LogsDedupStrategy.signature}>
|
||||
<LogLine {...defaultProps} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await screen.findByText('log message 1');
|
||||
expect(screen.getByText('2x')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Shows log lines with errors', async () => {
|
||||
log.hasError = true;
|
||||
jest.spyOn(log, 'errorMessage', 'get').mockReturnValue('error message');
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} dedupStrategy={LogsDedupStrategy.signature}>
|
||||
<LogLine {...defaultProps} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await screen.findByText('log message 1');
|
||||
expect(screen.getByLabelText('Has errors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Shows sampled log lines', async () => {
|
||||
log.isSampled = true;
|
||||
jest.spyOn(log, 'sampledMessage', 'get').mockReturnValue('sampled message');
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} dedupStrategy={LogsDedupStrategy.signature}>
|
||||
<LogLine {...defaultProps} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await screen.findByText('log message 1');
|
||||
expect(screen.getByLabelText('Is sampled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Does not falsely report sampled or errors in logs', async () => {
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} dedupStrategy={LogsDedupStrategy.signature}>
|
||||
<LogLine {...defaultProps} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await screen.findByText('log message 1');
|
||||
expect(screen.queryByLabelText('Has errors')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Is sampled')).not.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}
|
||||
/>
|
||||
);
|
||||
render(<LogLine {...defaultProps} />);
|
||||
expect(screen.queryByText('Copy log line')).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByLabelText('Log menu'));
|
||||
expect(screen.getByText('Copy log line')).toBeInTheDocument();
|
||||
@@ -143,17 +154,7 @@ describe('LogLine', () => {
|
||||
});
|
||||
|
||||
test('Highlights relevant tokens in the log line', () => {
|
||||
render(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
);
|
||||
render(<LogLine {...defaultProps} log={log} />);
|
||||
expect(screen.getByText('place')).toBeInTheDocument();
|
||||
expect(screen.getByText('1ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 KB')).toBeInTheDocument();
|
||||
@@ -163,15 +164,7 @@ describe('LogLine', () => {
|
||||
test('Can be disabled', () => {
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} syntaxHighlighting={false}>
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
<LogLine {...defaultProps} log={log} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.getByText(`place="luna" 1ms 3 KB`)).toBeInTheDocument();
|
||||
@@ -186,15 +179,7 @@ describe('LogLine', () => {
|
||||
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} syntaxHighlighting={false}>
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
<LogLine {...defaultProps} log={log} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.getByTestId('ansiLogLine')).toBeInTheDocument();
|
||||
@@ -209,17 +194,7 @@ describe('LogLine', () => {
|
||||
});
|
||||
|
||||
test('Logs are not collapsed by default', () => {
|
||||
render(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={true}
|
||||
/>
|
||||
);
|
||||
render(<LogLine {...defaultProps} />);
|
||||
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('show more')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -228,12 +203,7 @@ describe('LogLine', () => {
|
||||
log.collapsed = true;
|
||||
render(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
{...defaultProps}
|
||||
// Unwrapped logs
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
@@ -244,17 +214,7 @@ describe('LogLine', () => {
|
||||
|
||||
test('Long logs can be collapsed and expanded', async () => {
|
||||
log.collapsed = true;
|
||||
render(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={true}
|
||||
/>
|
||||
);
|
||||
render(<LogLine {...defaultProps} log={log} />);
|
||||
expect(screen.getByText('show more')).toBeVisible();
|
||||
await userEvent.click(screen.getByText('show more'));
|
||||
expect(await screen.findByText('show less')).toBeInTheDocument();
|
||||
@@ -265,21 +225,33 @@ describe('LogLine', () => {
|
||||
test('When the collapsed state changes invokes a callback to update virtualized sizes', async () => {
|
||||
log.collapsed = true;
|
||||
const onOverflow = jest.fn();
|
||||
render(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
onOverflow={onOverflow}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={true}
|
||||
/>
|
||||
);
|
||||
render(<LogLine {...defaultProps} onOverflow={onOverflow} log={log} />);
|
||||
await userEvent.click(await screen.findByText('show more'));
|
||||
await userEvent.click(await screen.findByText('show less'));
|
||||
expect(onOverflow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('Syncs the collapsed state with collapsed status changes in the log', async () => {
|
||||
log.collapsed = true;
|
||||
const { rerender } = render(<LogLine {...defaultProps} log={log} />);
|
||||
expect(screen.getByText('show more')).toBeVisible();
|
||||
|
||||
log.collapsed = undefined;
|
||||
rerender(<LogLine {...defaultProps} log={log} />);
|
||||
|
||||
expect(screen.queryByText('show more')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Syncs the collapsed state with wrapping changes', async () => {
|
||||
log.collapsed = true;
|
||||
const { rerender } = render(<LogLine {...defaultProps} log={log} />);
|
||||
expect(screen.getByText('show more')).toBeVisible();
|
||||
|
||||
rerender(<LogLine {...defaultProps} log={log} wrapLogMessage={false} />);
|
||||
|
||||
expect(screen.queryByText('show more')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('show less')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@ import { css } from '@emotion/css';
|
||||
import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { GrafanaTheme2, LogsDedupStrategy } from '@grafana/data';
|
||||
import { useTranslate } from '@grafana/i18n';
|
||||
import { Button } from '@grafana/ui';
|
||||
import { Button, Icon, Tooltip } from '@grafana/ui';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { LogMessageAnsi } from '../LogMessageAnsi';
|
||||
@@ -20,13 +20,14 @@ import {
|
||||
TRUNCATION_LINE_COUNT,
|
||||
} from './virtualization';
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
displayedFields: string[];
|
||||
index: number;
|
||||
log: LogListModel;
|
||||
showTime: boolean;
|
||||
style: CSSProperties;
|
||||
styles: LogLineStyles;
|
||||
onClick: (log: LogListModel) => void;
|
||||
onOverflow?: (index: number, id: string, height?: number) => void;
|
||||
variant?: 'infinite-scroll';
|
||||
wrapLogMessage: boolean;
|
||||
@@ -38,12 +39,13 @@ export const LogLine = ({
|
||||
log,
|
||||
style,
|
||||
styles,
|
||||
onClick,
|
||||
onOverflow,
|
||||
showTime,
|
||||
variant,
|
||||
wrapLogMessage,
|
||||
}: Props) => {
|
||||
const { onLogLineHover } = useLogListContext();
|
||||
const { detailsDisplayed, onLogLineHover } = useLogListContext();
|
||||
const [collapsed, setCollapsed] = useState<boolean | undefined>(
|
||||
wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined
|
||||
);
|
||||
@@ -59,7 +61,17 @@ export const LogLine = ({
|
||||
if (actualHeight) {
|
||||
onOverflow(index, log.uid, actualHeight);
|
||||
}
|
||||
}, [index, log.collapsed, log.uid, onOverflow, style.height]);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!wrapLogMessage) {
|
||||
setCollapsed(undefined);
|
||||
} else if (collapsed === undefined && log.collapsed !== undefined) {
|
||||
setCollapsed(log.collapsed);
|
||||
} else if (collapsed !== undefined && log.collapsed === undefined) {
|
||||
setCollapsed(log.collapsed);
|
||||
}
|
||||
}, [collapsed, log.collapsed, wrapLogMessage]);
|
||||
|
||||
const handleMouseOver = useCallback(() => onLogLineHover?.(log), [log, onLogLineHover]);
|
||||
|
||||
@@ -71,18 +83,26 @@ export const LogLine = ({
|
||||
}, [collapsed, index, log, onOverflow]);
|
||||
|
||||
const { t } = useTranslate();
|
||||
const handleClick = useCallback(() => {
|
||||
onClick(log);
|
||||
}, [log, onClick]);
|
||||
|
||||
const detailsShown = detailsDisplayed(log);
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div
|
||||
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`}
|
||||
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''} ${detailsShown ? styles.detailsDisplayed : ''}`}
|
||||
ref={onOverflow ? logLineRef : undefined}
|
||||
onMouseEnter={handleMouseOver}
|
||||
onFocus={handleMouseOver}
|
||||
>
|
||||
<LogLineMenu styles={styles} log={log} />
|
||||
{/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
|
||||
<div
|
||||
className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Log
|
||||
displayedFields={displayedFields}
|
||||
@@ -132,8 +152,43 @@ interface LogProps {
|
||||
}
|
||||
|
||||
const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => {
|
||||
const { dedupStrategy } = useLogListContext();
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<>
|
||||
{dedupStrategy !== LogsDedupStrategy.none && (
|
||||
<span className={`${styles.duplicates} field`}>
|
||||
{log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null}
|
||||
</span>
|
||||
)}
|
||||
{log.hasError && (
|
||||
<span className={`${styles.hasError} field`}>
|
||||
<Tooltip
|
||||
content={t('logs.log-line.tooltip-error', 'Error: {{errorMessage}}', { errorMessage: log.errorMessage })}
|
||||
placement="right"
|
||||
theme="error"
|
||||
>
|
||||
<Icon
|
||||
className={styles.logIconError}
|
||||
name="exclamation-triangle"
|
||||
aria-label={t('logs.log-line.has-error', 'Has errors')}
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
{log.isSampled && (
|
||||
<span className={`${styles.isSampled} field`}>
|
||||
<Tooltip content={log.sampledMessage ?? ''} placement="right" theme="info">
|
||||
<Icon
|
||||
className={styles.logIconInfo}
|
||||
name="info-circle"
|
||||
size="xs"
|
||||
aria-label={t('logs.log-line.is-sampled', 'Is sampled')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
{showTime && <span className={`${styles.timestamp} level-${log.logLevel} field`}>{log.timestamp}</span>}
|
||||
{
|
||||
// When logs are unwrapped, we want an empty column space to align with other log lines.
|
||||
@@ -206,7 +261,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
fontSize: theme.typography.fontSize,
|
||||
wordBreak: 'break-all',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
background: `hsla(0, 0%, 0%, 0.2)`,
|
||||
},
|
||||
@@ -256,6 +310,9 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
color: theme.colors.text.primary,
|
||||
},
|
||||
}),
|
||||
detailsDisplayed: css({
|
||||
background: `hsla(0, 0%, 0%, 0.2)`,
|
||||
}),
|
||||
pinnedLogLine: css({
|
||||
backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(),
|
||||
}),
|
||||
@@ -272,6 +329,33 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
color: theme.colors.text.disabled,
|
||||
display: 'inline-block',
|
||||
}),
|
||||
duplicates: css({
|
||||
display: 'inline-block',
|
||||
textAlign: 'center',
|
||||
width: theme.spacing(4.5),
|
||||
}),
|
||||
hasError: css({
|
||||
display: 'inline-block',
|
||||
width: theme.spacing(2),
|
||||
'& svg': {
|
||||
position: 'relative',
|
||||
top: -1,
|
||||
},
|
||||
}),
|
||||
isSampled: css({
|
||||
display: 'inline-block',
|
||||
width: theme.spacing(2),
|
||||
'& svg': {
|
||||
position: 'relative',
|
||||
top: -1,
|
||||
},
|
||||
}),
|
||||
logIconError: css({
|
||||
color: theme.colors.warning.main,
|
||||
}),
|
||||
logIconInfo: css({
|
||||
color: theme.colors.info.main,
|
||||
}),
|
||||
level: css({
|
||||
color: theme.colors.text.secondary,
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
@@ -302,12 +386,14 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
outline: 'solid 1px red',
|
||||
}),
|
||||
unwrappedLogLine: css({
|
||||
cursor: 'pointer',
|
||||
display: 'grid',
|
||||
gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER),
|
||||
whiteSpace: 'pre',
|
||||
paddingBottom: theme.spacing(0.75),
|
||||
}),
|
||||
wrappedLogLine: css({
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
paddingBottom: theme.spacing(0.75),
|
||||
whiteSpace: 'pre-wrap',
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Resizable } from 're-resizable';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useTranslate } from '@grafana/i18n';
|
||||
import { IconButton, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
||||
|
||||
import { LogDetails } from '../LogDetails';
|
||||
import { getLogRowStyles } from '../getLogRowStyles';
|
||||
|
||||
import { useLogListContext } from './LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
interface Props {
|
||||
containerElement: HTMLDivElement;
|
||||
getFieldLinks?: GetFieldLinksFn;
|
||||
logs: LogListModel[];
|
||||
onResize(): void;
|
||||
}
|
||||
|
||||
export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize }: Props) => {
|
||||
const {
|
||||
app,
|
||||
closeDetails,
|
||||
detailsWidth,
|
||||
displayedFields,
|
||||
isLabelFilterActive,
|
||||
onClickFilterLabel,
|
||||
onClickFilterOutLabel,
|
||||
onClickShowField,
|
||||
onClickHideField,
|
||||
onPinLine,
|
||||
pinLineButtonTooltipTitle,
|
||||
setDetailsWidth,
|
||||
showDetails,
|
||||
wrapLogMessage,
|
||||
} = useLogListContext();
|
||||
const getRows = useCallback(() => logs, [logs]);
|
||||
const logRowsStyles = getLogRowStyles(useTheme2());
|
||||
const styles = useStyles2(getStyles);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { t } = useTranslate();
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
setDetailsWidth(containerRef.current.clientWidth);
|
||||
}
|
||||
onResize();
|
||||
}, [onResize, setDetailsWidth]);
|
||||
|
||||
return (
|
||||
<Resizable onResize={handleResize} defaultSize={{ width: detailsWidth, height: containerElement.clientHeight }}>
|
||||
<div className={styles.container} ref={containerRef}>
|
||||
<IconButton
|
||||
name="times"
|
||||
className={styles.closeIcon}
|
||||
aria-label={t('logs.log-details.close', 'Close log details')}
|
||||
onClick={closeDetails}
|
||||
/>
|
||||
<table width="100%">
|
||||
<tbody>
|
||||
<LogDetails
|
||||
getRows={getRows}
|
||||
mode="sidebar"
|
||||
row={showDetails[0]}
|
||||
showDuplicates={false}
|
||||
styles={logRowsStyles}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
onPinLine={onPinLine}
|
||||
getFieldLinks={getFieldLinks}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
onClickShowField={onClickShowField}
|
||||
onClickHideField={onClickHideField}
|
||||
hasError={showDetails[0].hasError}
|
||||
displayedFields={displayedFields}
|
||||
app={app}
|
||||
isFilterLabelActive={isLabelFilterActive}
|
||||
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Resizable>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
}),
|
||||
closeIcon: css({
|
||||
position: 'absolute',
|
||||
top: theme.spacing(1),
|
||||
right: theme.spacing(1.5),
|
||||
}),
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { createLogLine } from '../__mocks__/logRow';
|
||||
import { getStyles } from './LogLine';
|
||||
import { LogLineMenu } from './LogLineMenu';
|
||||
import { LogListContextProvider } from './LogListContext';
|
||||
import { defaultProps, defaultValue } from './__mocks__/LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
jest.mock('./LogListContext');
|
||||
@@ -15,6 +16,8 @@ jest.mock('./LogListContext');
|
||||
const theme = createTheme();
|
||||
const styles = getStyles(theme);
|
||||
const contextProps = {
|
||||
...defaultProps,
|
||||
...defaultValue,
|
||||
app: CoreApp.Unknown,
|
||||
dedupStrategy: LogsDedupStrategy.exact,
|
||||
displayedFields: [],
|
||||
@@ -112,5 +115,25 @@ describe('LogLineMenu', () => {
|
||||
await userEvent.click(screen.getByText('Unpin log'));
|
||||
expect(onUnpinLine).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Allows to open log details', async () => {
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} enableLogDetails={true}>
|
||||
<LogLineMenu log={log} styles={styles} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Log menu'));
|
||||
await screen.findByText('Show log details');
|
||||
});
|
||||
|
||||
test('Does not show log details option when disabled', async () => {
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} enableLogDetails={false}>
|
||||
<LogLineMenu log={log} styles={styles} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Log menu'));
|
||||
expect(screen.queryByText('Show log details')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,17 @@ interface Props {
|
||||
}
|
||||
|
||||
export const LogLineMenu = ({ log, styles }: Props) => {
|
||||
const { getRowContextQuery, onOpenContext, onPermalinkClick, onPinLine, onUnpinLine, logSupportsContext } =
|
||||
useLogListContext();
|
||||
const {
|
||||
enableLogDetails,
|
||||
detailsDisplayed,
|
||||
getRowContextQuery,
|
||||
onOpenContext,
|
||||
onPermalinkClick,
|
||||
onPinLine,
|
||||
onUnpinLine,
|
||||
logSupportsContext,
|
||||
toggleDetails,
|
||||
} = useLogListContext();
|
||||
const pinned = useLogIsPinned(log);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
@@ -48,6 +57,10 @@ export const LogLineMenu = ({ log, styles }: Props) => {
|
||||
[onOpenContext, getRowContextQuery, log]
|
||||
);
|
||||
|
||||
const toggleLogDetails = useCallback(() => {
|
||||
toggleDetails(log);
|
||||
}, [log, toggleDetails]);
|
||||
|
||||
const togglePinning = useCallback(() => {
|
||||
if (pinned) {
|
||||
onUnpinLine?.(log);
|
||||
@@ -61,11 +74,16 @@ export const LogLineMenu = ({ log, styles }: Props) => {
|
||||
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')} />
|
||||
{enableLogDetails && (
|
||||
<Menu.Item
|
||||
onClick={toggleLogDetails}
|
||||
label={
|
||||
detailsDisplayed(log)
|
||||
? t('logs.log-line-menu.show-details', 'Hide log details')
|
||||
: t('logs.log-line-menu.hide-details', 'Show log details')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(shouldlogSupportsContext || onPinLine || onUnpinLine) && <Menu.Divider />}
|
||||
{shouldlogSupportsContext && (
|
||||
<Menu.Item onClick={showContext} label={t('logs.log-line-menu.show-context', 'Show context')} />
|
||||
)}
|
||||
@@ -75,21 +93,28 @@ export const LogLineMenu = ({ log, styles }: Props) => {
|
||||
{pinned && onUnpinLine && (
|
||||
<Menu.Item onClick={togglePinning} label={t('logs.log-line-menu.unpin-from-outline', 'Unpin log')} />
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<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')} />
|
||||
)}
|
||||
</Menu>
|
||||
),
|
||||
[
|
||||
copyLinkToLogLine,
|
||||
copyLogLine,
|
||||
log.rowId,
|
||||
log.uid,
|
||||
detailsDisplayed,
|
||||
enableLogDetails,
|
||||
log,
|
||||
onPermalinkClick,
|
||||
onPinLine,
|
||||
onUnpinLine,
|
||||
pinned,
|
||||
shouldlogSupportsContext,
|
||||
showContext,
|
||||
togglePinning,
|
||||
t,
|
||||
toggleLogDetails,
|
||||
togglePinning,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -5,76 +5,86 @@ import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortO
|
||||
|
||||
import { createLogRow } from '../__mocks__/logRow';
|
||||
|
||||
import { LogList } from './LogList';
|
||||
|
||||
const logs: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' })];
|
||||
import { LogList, Props } from './LogList';
|
||||
|
||||
describe('LogList', () => {
|
||||
let logs: LogRowModel[], defaultProps: Props;
|
||||
beforeEach(() => {
|
||||
logs = [
|
||||
createLogRow({ uid: '1', labels: { name_of_the_label: 'value of the label' } }),
|
||||
createLogRow({ uid: '2' }),
|
||||
];
|
||||
defaultProps = {
|
||||
app: CoreApp.Explore,
|
||||
containerElement: document.createElement('div'),
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
displayedFields: [],
|
||||
enableLogDetails: false,
|
||||
logs,
|
||||
showControls: false,
|
||||
showTime: false,
|
||||
sortOrder: LogsSortOrder.Descending,
|
||||
timeRange: getDefaultTimeRange(),
|
||||
timeZone: 'browser',
|
||||
wrapLogMessage: false,
|
||||
};
|
||||
});
|
||||
|
||||
test('Renders a list of logs without controls ', async () => {
|
||||
const containerElement = document.createElement('div');
|
||||
render(
|
||||
<LogList
|
||||
app={CoreApp.Explore}
|
||||
containerElement={containerElement}
|
||||
dedupStrategy={LogsDedupStrategy.none}
|
||||
displayedFields={[]}
|
||||
logs={logs}
|
||||
showControls={false}
|
||||
showTime={false}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
timeRange={getDefaultTimeRange()}
|
||||
timeZone={'browser'}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
);
|
||||
render(<LogList {...defaultProps} />);
|
||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('log message 2')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Scroll to bottom')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Renders a list of logs with controls', async () => {
|
||||
const containerElement = document.createElement('div');
|
||||
render(
|
||||
<LogList
|
||||
app={CoreApp.Explore}
|
||||
containerElement={containerElement}
|
||||
dedupStrategy={LogsDedupStrategy.none}
|
||||
displayedFields={[]}
|
||||
logs={logs}
|
||||
showControls={true}
|
||||
showTime={false}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
timeRange={getDefaultTimeRange()}
|
||||
timeZone={'browser'}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
);
|
||||
render(<LogList {...defaultProps} showControls={true} />);
|
||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('log message 2')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Reports mouse over events', async () => {
|
||||
const containerElement = document.createElement('div');
|
||||
const onLogRowHover = jest.fn();
|
||||
render(
|
||||
<LogList
|
||||
app={CoreApp.Explore}
|
||||
containerElement={containerElement}
|
||||
dedupStrategy={LogsDedupStrategy.none}
|
||||
displayedFields={[]}
|
||||
logs={logs}
|
||||
onLogLineHover={onLogRowHover}
|
||||
showControls={true}
|
||||
showTime={false}
|
||||
sortOrder={LogsSortOrder.Descending}
|
||||
timeRange={getDefaultTimeRange()}
|
||||
timeZone={'browser'}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
);
|
||||
render(<LogList {...defaultProps} onLogLineHover={onLogRowHover} />);
|
||||
await userEvent.hover(screen.getByText('log message 1'));
|
||||
expect(onLogRowHover).toHaveBeenCalledTimes(1);
|
||||
expect(onLogRowHover).toHaveBeenCalledWith(expect.objectContaining(logs[0]));
|
||||
});
|
||||
|
||||
test('Supports showing log details', async () => {
|
||||
const onClickFilterLabel = jest.fn();
|
||||
const onClickFilterOutLabel = jest.fn();
|
||||
const onClickShowField = jest.fn();
|
||||
|
||||
render(
|
||||
<LogList
|
||||
{...defaultProps}
|
||||
enableLogDetails={true}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
onClickShowField={onClickShowField}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('log message 1'));
|
||||
await screen.findByText('Fields');
|
||||
|
||||
expect(screen.getByText('name_of_the_label')).toBeInTheDocument();
|
||||
expect(screen.getByText('value of the label')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Filter for value in query A'));
|
||||
expect(onClickFilterLabel).toHaveBeenCalledTimes(1);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Filter out value in query A'));
|
||||
expect(onClickFilterOutLabel).toHaveBeenCalledTimes(1);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Show this field instead of the message'));
|
||||
expect(onClickShowField).toHaveBeenCalledTimes(1);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Close log details'));
|
||||
|
||||
expect(screen.queryByText('Fields')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Close log details')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { VariableSizeList } from 'react-window';
|
||||
import {
|
||||
AbsoluteTimeRange,
|
||||
CoreApp,
|
||||
DataFrame,
|
||||
EventBus,
|
||||
EventBusSrv,
|
||||
LogLevel,
|
||||
@@ -22,6 +23,7 @@ import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
||||
|
||||
import { InfiniteScroll } from './InfiniteScroll';
|
||||
import { getGridTemplateColumns } from './LogLine';
|
||||
import { LogLineDetails } from './LogLineDetails';
|
||||
import { GetRowContextQueryFn } from './LogLineMenu';
|
||||
import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext';
|
||||
import { LogListControls } from './LogListControls';
|
||||
@@ -36,23 +38,31 @@ import {
|
||||
storeLogLineSize,
|
||||
} from './virtualization';
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
app: CoreApp;
|
||||
containerElement: HTMLDivElement;
|
||||
dedupStrategy: LogsDedupStrategy;
|
||||
displayedFields: string[];
|
||||
enableLogDetails: boolean;
|
||||
eventBus?: EventBus;
|
||||
filterLevels?: LogLevel[];
|
||||
getFieldLinks?: GetFieldLinksFn;
|
||||
getRowContextQuery?: GetRowContextQueryFn;
|
||||
grammar?: Grammar;
|
||||
initialScrollPosition?: 'top' | 'bottom';
|
||||
isLabelFilterActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
|
||||
loading?: boolean;
|
||||
loadMore?: (range: AbsoluteTimeRange) => void;
|
||||
logOptionsStorageKey?: string;
|
||||
logs: LogRowModel[];
|
||||
logsMeta?: LogsMetaItem[];
|
||||
logSupportsContext?: (row: LogRowModel) => boolean;
|
||||
onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void;
|
||||
onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void;
|
||||
onClickFilterString?: (value: string, refId?: string) => void;
|
||||
onClickFilterOutString?: (value: string, refId?: string) => void;
|
||||
onClickShowField?: (key: string) => void;
|
||||
onClickHideField?: (key: string) => void;
|
||||
onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
|
||||
onLogLineHover?: (row?: LogRowModel) => void;
|
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
|
||||
@@ -64,7 +74,6 @@ interface Props {
|
||||
showControls: boolean;
|
||||
showTime: boolean;
|
||||
sortOrder: LogsSortOrder;
|
||||
storageKey?: string;
|
||||
timeRange: TimeRange;
|
||||
timeZone: string;
|
||||
syntaxHighlighting?: boolean;
|
||||
@@ -75,7 +84,14 @@ export type LogListControlOptions = LogListState;
|
||||
|
||||
type LogListComponentProps = Omit<
|
||||
Props,
|
||||
'app' | 'dedupStrategy' | 'displayedFields' | 'showTime' | 'sortOrder' | 'syntaxHighlighting' | 'wrapLogMessage'
|
||||
| 'app'
|
||||
| 'dedupStrategy'
|
||||
| 'displayedFields'
|
||||
| 'enableLogDetails'
|
||||
| 'showTime'
|
||||
| 'sortOrder'
|
||||
| 'syntaxHighlighting'
|
||||
| 'wrapLogMessage'
|
||||
>;
|
||||
|
||||
export const LogList = ({
|
||||
@@ -83,18 +99,26 @@ export const LogList = ({
|
||||
displayedFields,
|
||||
containerElement,
|
||||
dedupStrategy,
|
||||
enableLogDetails,
|
||||
eventBus,
|
||||
filterLevels,
|
||||
getFieldLinks,
|
||||
getRowContextQuery,
|
||||
grammar,
|
||||
initialScrollPosition = 'top',
|
||||
isLabelFilterActive,
|
||||
loading,
|
||||
loadMore,
|
||||
logOptionsStorageKey,
|
||||
logs,
|
||||
logsMeta,
|
||||
logSupportsContext,
|
||||
onClickFilterLabel,
|
||||
onClickFilterOutLabel,
|
||||
onClickFilterString,
|
||||
onClickFilterOutString,
|
||||
onClickShowField,
|
||||
onClickHideField,
|
||||
onLogOptionsChange,
|
||||
onLogLineHover,
|
||||
onPermalinkClick,
|
||||
@@ -114,14 +138,23 @@ export const LogList = ({
|
||||
return (
|
||||
<LogListContextProvider
|
||||
app={app}
|
||||
containerElement={containerElement}
|
||||
dedupStrategy={dedupStrategy}
|
||||
displayedFields={displayedFields}
|
||||
enableLogDetails={enableLogDetails}
|
||||
filterLevels={filterLevels}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
isLabelFilterActive={isLabelFilterActive}
|
||||
logs={logs}
|
||||
logsMeta={logsMeta}
|
||||
logOptionsStorageKey={logOptionsStorageKey}
|
||||
logSupportsContext={logSupportsContext}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
onClickFilterString={onClickFilterString}
|
||||
onClickFilterOutString={onClickFilterOutString}
|
||||
onClickShowField={onClickShowField}
|
||||
onClickHideField={onClickHideField}
|
||||
onLogOptionsChange={onLogOptionsChange}
|
||||
onLogLineHover={onLogLineHover}
|
||||
onPermalinkClick={onPermalinkClick}
|
||||
@@ -166,7 +199,18 @@ const LogListComponent = ({
|
||||
timeRange,
|
||||
timeZone,
|
||||
}: LogListComponentProps) => {
|
||||
const { app, displayedFields, filterLevels, forceEscape, showTime, sortOrder, wrapLogMessage } = useLogListContext();
|
||||
const {
|
||||
app,
|
||||
displayedFields,
|
||||
dedupStrategy,
|
||||
filterLevels,
|
||||
forceEscape,
|
||||
showDetails,
|
||||
showTime,
|
||||
sortOrder,
|
||||
toggleDetails,
|
||||
wrapLogMessage,
|
||||
} = useLogListContext();
|
||||
const [processedLogs, setProcessedLogs] = useState<LogListModel[]>([]);
|
||||
const [listHeight, setListHeight] = useState(
|
||||
app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight
|
||||
@@ -174,12 +218,21 @@ const LogListComponent = ({
|
||||
const theme = useTheme2();
|
||||
const listRef = useRef<VariableSizeList | null>(null);
|
||||
const widthRef = useRef(containerElement.clientWidth);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const dimensions = useMemo(
|
||||
() => (wrapLogMessage ? [] : calculateFieldDimensions(processedLogs, displayedFields)),
|
||||
[displayedFields, processedLogs, wrapLogMessage]
|
||||
);
|
||||
const styles = getStyles(dimensions, { showTime });
|
||||
const widthContainer = wrapperRef.current ?? containerElement;
|
||||
|
||||
const debouncedResetAfterIndex = useMemo(() => {
|
||||
return debounce((index: number) => {
|
||||
listRef.current?.resetAfterIndex(index);
|
||||
overflowIndexRef.current = Infinity;
|
||||
}, 25);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
initVirtualization(theme);
|
||||
@@ -205,7 +258,7 @@ const LogListComponent = ({
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.resetAfterIndex(0);
|
||||
}, [wrapLogMessage]);
|
||||
}, [wrapLogMessage, showDetails, displayedFields]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = debounce(() => {
|
||||
@@ -219,21 +272,23 @@ const LogListComponent = ({
|
||||
}, [app, containerElement.clientHeight]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (widthRef.current === containerElement.clientWidth) {
|
||||
if (widthRef.current === widthContainer.clientWidth) {
|
||||
return;
|
||||
}
|
||||
widthRef.current = containerElement.clientWidth;
|
||||
listRef.current?.resetAfterIndex(0);
|
||||
widthRef.current = widthContainer.clientWidth;
|
||||
debouncedResetAfterIndex(0);
|
||||
});
|
||||
|
||||
const overflowIndexRef = useRef(Infinity);
|
||||
const handleOverflow = useCallback(
|
||||
(index: number, id: string, height?: number) => {
|
||||
if (containerElement && height !== undefined) {
|
||||
storeLogLineSize(id, containerElement, height);
|
||||
if (height !== undefined) {
|
||||
storeLogLineSize(id, widthContainer, height);
|
||||
}
|
||||
listRef.current?.resetAfterIndex(index);
|
||||
overflowIndexRef.current = index < overflowIndexRef.current ? index : overflowIndexRef.current;
|
||||
debouncedResetAfterIndex(overflowIndexRef.current);
|
||||
},
|
||||
[containerElement]
|
||||
[debouncedResetAfterIndex, widthContainer]
|
||||
);
|
||||
|
||||
const handleScrollPosition = useCallback(() => {
|
||||
@@ -245,6 +300,17 @@ const LogListComponent = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleLogLineClick = useCallback(
|
||||
(log: LogListModel) => {
|
||||
toggleDetails(log);
|
||||
},
|
||||
[toggleDetails]
|
||||
);
|
||||
|
||||
const handleLogDetailsResize = useCallback(() => {
|
||||
debouncedResetAfterIndex(0);
|
||||
}, [debouncedResetAfterIndex]);
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
filterLevels.length === 0 ? processedLogs : processedLogs.filter((log) => filterLevels.includes(log.logLevel)),
|
||||
@@ -253,41 +319,52 @@ const LogListComponent = ({
|
||||
|
||||
return (
|
||||
<div className={styles.logListContainer}>
|
||||
<InfiniteScroll
|
||||
displayedFields={displayedFields}
|
||||
handleOverflow={handleOverflow}
|
||||
logs={filteredLogs}
|
||||
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, filteredLogs, containerElement, displayedFields, {
|
||||
wrap: wrapLogMessage,
|
||||
showControls,
|
||||
showTime,
|
||||
})}
|
||||
itemKey={getItemKey}
|
||||
layout="vertical"
|
||||
onItemsRendered={onItemsRendered}
|
||||
outerRef={scrollRef}
|
||||
ref={listRef}
|
||||
style={{ overflowY: 'scroll' }}
|
||||
width="100%"
|
||||
>
|
||||
{Renderer}
|
||||
</VariableSizeList>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
<div className={styles.logListWrapper} ref={wrapperRef}>
|
||||
<InfiniteScroll
|
||||
displayedFields={displayedFields}
|
||||
handleOverflow={handleOverflow}
|
||||
logs={filteredLogs}
|
||||
loadMore={loadMore}
|
||||
onClick={handleLogLineClick}
|
||||
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, filteredLogs, widthContainer, displayedFields, {
|
||||
showDuplicates: dedupStrategy !== LogsDedupStrategy.none,
|
||||
showTime,
|
||||
wrap: wrapLogMessage,
|
||||
})}
|
||||
itemKey={getItemKey}
|
||||
layout="vertical"
|
||||
onItemsRendered={onItemsRendered}
|
||||
outerRef={scrollRef}
|
||||
ref={listRef}
|
||||
style={{ overflowY: 'scroll' }}
|
||||
width="100%"
|
||||
>
|
||||
{Renderer}
|
||||
</VariableSizeList>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
{showDetails.length > 0 && (
|
||||
<LogLineDetails
|
||||
containerElement={containerElement}
|
||||
getFieldLinks={getFieldLinks}
|
||||
logs={filteredLogs}
|
||||
onResize={handleLogDetailsResize}
|
||||
/>
|
||||
)}
|
||||
{showControls && <LogListControls eventBus={eventBus} />}
|
||||
</div>
|
||||
);
|
||||
@@ -305,6 +382,9 @@ function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: bo
|
||||
logListContainer: css({
|
||||
display: 'flex',
|
||||
}),
|
||||
logListWrapper: css({
|
||||
width: '100%',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,19 +4,11 @@ import { ReactNode } from 'react';
|
||||
import { createLogLine } from '../__mocks__/logRow';
|
||||
|
||||
import { useLogListContextData, useLogListContext, useLogIsPinned, LogListContext } from './LogListContext';
|
||||
import { defaultProps } from './__mocks__/LogListContext';
|
||||
import { defaultValue } from './__mocks__/LogListContext';
|
||||
|
||||
const log = createLogLine({ rowId: 'yep' });
|
||||
const value = {
|
||||
...defaultProps,
|
||||
downloadLogs: jest.fn(),
|
||||
getRowContextQuery: jest.fn(),
|
||||
logSupportsContext: jest.fn(),
|
||||
onPermalinkClick: jest.fn(),
|
||||
onPinLine: jest.fn(),
|
||||
onOpenContext: jest.fn(),
|
||||
onUnpinLine: jest.fn(),
|
||||
pinLineButtonTooltipTitle: 'test',
|
||||
...defaultValue,
|
||||
pinnedLogs: ['yep'],
|
||||
};
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
import {
|
||||
CoreApp,
|
||||
DataFrame,
|
||||
LogLevel,
|
||||
LogRowModel,
|
||||
LogsDedupStrategy,
|
||||
@@ -24,13 +25,18 @@ import { PopoverContent } from '@grafana/ui';
|
||||
import { DownloadFormat, downloadLogs as download } from '../../utils';
|
||||
|
||||
import { GetRowContextQueryFn } from './LogLineMenu';
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
export interface LogListContextData extends Omit<Props, 'logs' | 'logsMeta' | 'showControls'> {
|
||||
export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> {
|
||||
closeDetails: () => void;
|
||||
detailsDisplayed: (log: LogListModel) => boolean;
|
||||
detailsWidth: number;
|
||||
downloadLogs: (format: DownloadFormat) => void;
|
||||
enableLogDetails: boolean;
|
||||
filterLevels: LogLevel[];
|
||||
hasUnescapedContent?: boolean;
|
||||
setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void;
|
||||
setDisplayedFields: (displayedFields: string[]) => void;
|
||||
setDetailsWidth: (width: number) => void;
|
||||
setFilterLevels: (filterLevels: LogLevel[]) => void;
|
||||
setForceEscape: (forceEscape: boolean) => void;
|
||||
setLogListState: Dispatch<SetStateAction<LogListState>>;
|
||||
@@ -41,17 +47,23 @@ export interface LogListContextData extends Omit<Props, 'logs' | 'logsMeta' | 's
|
||||
setShowUniqueLabels: (showUniqueLabels: boolean) => void;
|
||||
setSortOrder: (sortOrder: LogsSortOrder) => void;
|
||||
setWrapLogMessage: (showTime: boolean) => void;
|
||||
showDetails: LogListModel[];
|
||||
toggleDetails: (log: LogListModel) => void;
|
||||
}
|
||||
|
||||
export const LogListContext = createContext<LogListContextData>({
|
||||
app: CoreApp.Unknown,
|
||||
closeDetails: () => {},
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
detailsDisplayed: () => false,
|
||||
detailsWidth: 0,
|
||||
displayedFields: [],
|
||||
downloadLogs: () => {},
|
||||
enableLogDetails: false,
|
||||
filterLevels: [],
|
||||
hasUnescapedContent: false,
|
||||
setDedupStrategy: () => {},
|
||||
setDisplayedFields: () => {},
|
||||
setDetailsWidth: () => {},
|
||||
setFilterLevels: () => {},
|
||||
setForceEscape: () => {},
|
||||
setLogListState: () => {},
|
||||
@@ -62,9 +74,11 @@ export const LogListContext = createContext<LogListContextData>({
|
||||
setSortOrder: () => {},
|
||||
setSyntaxHighlighting: () => {},
|
||||
setWrapLogMessage: () => {},
|
||||
showDetails: [],
|
||||
showTime: true,
|
||||
sortOrder: LogsSortOrder.Ascending,
|
||||
syntaxHighlighting: true,
|
||||
toggleDetails: () => {},
|
||||
wrapLogMessage: false,
|
||||
});
|
||||
|
||||
@@ -85,7 +99,6 @@ export const useLogIsPinned = (log: LogRowModel) => {
|
||||
export type LogListState = Pick<
|
||||
LogListContextData,
|
||||
| 'dedupStrategy'
|
||||
| 'displayedFields'
|
||||
| 'forceEscape'
|
||||
| 'filterLevels'
|
||||
| 'hasUnescapedContent'
|
||||
@@ -101,16 +114,25 @@ export type LogListState = Pick<
|
||||
export interface Props {
|
||||
app: CoreApp;
|
||||
children?: ReactNode;
|
||||
containerElement?: HTMLDivElement;
|
||||
dedupStrategy: LogsDedupStrategy;
|
||||
displayedFields: string[];
|
||||
enableLogDetails: boolean;
|
||||
filterLevels?: LogLevel[];
|
||||
forceEscape?: boolean;
|
||||
hasUnescapedContent?: boolean;
|
||||
getRowContextQuery?: GetRowContextQueryFn;
|
||||
isLabelFilterActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
|
||||
logs: LogRowModel[];
|
||||
logsMeta?: LogsMetaItem[];
|
||||
logOptionsStorageKey?: string;
|
||||
logSupportsContext?: (row: LogRowModel) => boolean;
|
||||
onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void;
|
||||
onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void;
|
||||
onClickFilterString?: (value: string, refId?: string) => void;
|
||||
onClickFilterOutString?: (value: string, refId?: string) => void;
|
||||
onClickShowField?: (key: string) => void;
|
||||
onClickHideField?: (key: string) => void;
|
||||
onLogOptionsChange?: (option: keyof LogListState, value: string | boolean | string[]) => void;
|
||||
onLogLineHover?: (row?: LogRowModel) => void;
|
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
|
||||
@@ -131,16 +153,25 @@ export interface Props {
|
||||
export const LogListContextProvider = ({
|
||||
app,
|
||||
children,
|
||||
containerElement,
|
||||
enableLogDetails,
|
||||
dedupStrategy,
|
||||
displayedFields,
|
||||
filterLevels,
|
||||
forceEscape = false,
|
||||
hasUnescapedContent,
|
||||
isLabelFilterActive,
|
||||
getRowContextQuery,
|
||||
logs,
|
||||
logsMeta,
|
||||
logOptionsStorageKey,
|
||||
logSupportsContext,
|
||||
onClickFilterLabel,
|
||||
onClickFilterOutLabel,
|
||||
onClickFilterString,
|
||||
onClickFilterOutString,
|
||||
onClickShowField,
|
||||
onClickHideField,
|
||||
onLogOptionsChange,
|
||||
onLogLineHover,
|
||||
onPermalinkClick,
|
||||
@@ -159,7 +190,6 @@ export const LogListContextProvider = ({
|
||||
}: Props) => {
|
||||
const [logListState, setLogListState] = useState<LogListState>({
|
||||
dedupStrategy,
|
||||
displayedFields,
|
||||
filterLevels:
|
||||
filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []),
|
||||
forceEscape,
|
||||
@@ -172,6 +202,7 @@ export const LogListContextProvider = ({
|
||||
syntaxHighlighting,
|
||||
wrapLogMessage,
|
||||
});
|
||||
const [showDetails, setShowDetails] = useState<LogListModel[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Props are updated in the context only of the panel is being externally controlled.
|
||||
@@ -186,9 +217,6 @@ export const LogListContextProvider = ({
|
||||
syntaxHighlighting,
|
||||
wrapLogMessage,
|
||||
};
|
||||
if (!shallowCompare(logListState.displayedFields, displayedFields)) {
|
||||
newState.displayedFields = displayedFields;
|
||||
}
|
||||
if (!shallowCompare(logListState.pinnedLogs ?? [], pinnedLogs ?? [])) {
|
||||
newState.pinnedLogs = pinnedLogs;
|
||||
}
|
||||
@@ -198,7 +226,6 @@ export const LogListContextProvider = ({
|
||||
}, [
|
||||
app,
|
||||
dedupStrategy,
|
||||
displayedFields,
|
||||
logListState,
|
||||
pinnedLogs,
|
||||
showControls,
|
||||
@@ -223,6 +250,11 @@ export const LogListContextProvider = ({
|
||||
}
|
||||
}, [hasUnescapedContent, logListState]);
|
||||
|
||||
const detailsDisplayed = useCallback(
|
||||
(log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid),
|
||||
[showDetails]
|
||||
);
|
||||
|
||||
const setDedupStrategy = useCallback(
|
||||
(dedupStrategy: LogsDedupStrategy) => {
|
||||
setLogListState({ ...logListState, dedupStrategy });
|
||||
@@ -231,14 +263,6 @@ export const LogListContextProvider = ({
|
||||
[logListState, onLogOptionsChange]
|
||||
);
|
||||
|
||||
const setDisplayedFields = useCallback(
|
||||
(displayedFields: string[]) => {
|
||||
setLogListState({ ...logListState, displayedFields });
|
||||
onLogOptionsChange?.('displayedFields', displayedFields);
|
||||
},
|
||||
[logListState, onLogOptionsChange]
|
||||
);
|
||||
|
||||
const setForceEscape = useCallback(
|
||||
(forceEscape: boolean) => {
|
||||
setLogListState({ ...logListState, forceEscape });
|
||||
@@ -339,18 +363,64 @@ export const LogListContextProvider = ({
|
||||
[logListState.filterLevels, logs, logsMeta]
|
||||
);
|
||||
|
||||
const closeDetails = useCallback(() => {
|
||||
setShowDetails([]);
|
||||
}, []);
|
||||
|
||||
const toggleDetails = useCallback(
|
||||
(log: LogListModel) => {
|
||||
if (!enableLogDetails) {
|
||||
return;
|
||||
}
|
||||
const found = showDetails.findIndex((stateLog) => stateLog === log || stateLog.uid === log.uid);
|
||||
if (found >= 0) {
|
||||
setShowDetails(showDetails.filter((stateLog) => stateLog !== log && stateLog.uid !== log.uid));
|
||||
} else {
|
||||
// Supporting one displayed details for now
|
||||
setShowDetails([log]);
|
||||
}
|
||||
},
|
||||
[enableLogDetails, showDetails]
|
||||
);
|
||||
|
||||
const setDetailsWidth = useCallback(
|
||||
(width: number) => {
|
||||
if (!logOptionsStorageKey) {
|
||||
return;
|
||||
}
|
||||
store.set(`${logOptionsStorageKey}.detailsWidth`, width);
|
||||
},
|
||||
[logOptionsStorageKey]
|
||||
);
|
||||
|
||||
const defaultWidth = (containerElement?.clientWidth ?? 0) * 0.4;
|
||||
const detailsWidth = logOptionsStorageKey
|
||||
? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`), 10)
|
||||
: defaultWidth;
|
||||
|
||||
return (
|
||||
<LogListContext.Provider
|
||||
value={{
|
||||
app,
|
||||
closeDetails,
|
||||
detailsDisplayed,
|
||||
dedupStrategy: logListState.dedupStrategy,
|
||||
displayedFields: logListState.displayedFields,
|
||||
detailsWidth: detailsWidth || defaultWidth,
|
||||
displayedFields,
|
||||
downloadLogs,
|
||||
enableLogDetails,
|
||||
filterLevels: logListState.filterLevels,
|
||||
forceEscape: logListState.forceEscape,
|
||||
hasUnescapedContent: logListState.hasUnescapedContent,
|
||||
isLabelFilterActive,
|
||||
getRowContextQuery,
|
||||
logSupportsContext,
|
||||
onClickFilterLabel,
|
||||
onClickFilterOutLabel,
|
||||
onClickFilterString,
|
||||
onClickFilterOutString,
|
||||
onClickShowField,
|
||||
onClickHideField,
|
||||
onLogLineHover,
|
||||
onPermalinkClick,
|
||||
onPinLine,
|
||||
@@ -360,7 +430,7 @@ export const LogListContextProvider = ({
|
||||
pinnedLogs: logListState.pinnedLogs,
|
||||
prettifyJSON: logListState.prettifyJSON,
|
||||
setDedupStrategy,
|
||||
setDisplayedFields,
|
||||
setDetailsWidth,
|
||||
setFilterLevels,
|
||||
setForceEscape,
|
||||
setLogListState,
|
||||
@@ -371,10 +441,12 @@ export const LogListContextProvider = ({
|
||||
setSortOrder,
|
||||
setSyntaxHighlighting,
|
||||
setWrapLogMessage,
|
||||
showDetails,
|
||||
showTime: logListState.showTime,
|
||||
showUniqueLabels: logListState.showUniqueLabels,
|
||||
sortOrder: logListState.sortOrder,
|
||||
syntaxHighlighting: logListState.syntaxHighlighting,
|
||||
toggleDetails,
|
||||
wrapLogMessage: logListState.wrapLogMessage,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -14,8 +14,10 @@ jest.mock('../../utils');
|
||||
|
||||
const contextProps = {
|
||||
app: CoreApp.Unknown,
|
||||
containerElement: document.createElement('div'),
|
||||
dedupStrategy: LogsDedupStrategy.exact,
|
||||
displayedFields: [],
|
||||
enableLogDetails: false,
|
||||
logs: [],
|
||||
showControls: true,
|
||||
showTime: false,
|
||||
|
||||
@@ -395,6 +395,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
paddingLeft: theme.spacing(1),
|
||||
borderLeft: `solid 1px ${theme.colors.border.medium}`,
|
||||
overflow: 'hidden',
|
||||
minWidth: theme.spacing(4),
|
||||
}),
|
||||
scrollToTopButton: css({
|
||||
margin: 0,
|
||||
|
||||
@@ -6,12 +6,17 @@ import { LogListContextData, Props } from '../LogListContext';
|
||||
|
||||
export const LogListContext = createContext<LogListContextData>({
|
||||
app: CoreApp.Unknown,
|
||||
closeDetails: () => {},
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
detailsDisplayed: () => false,
|
||||
detailsWidth: 0,
|
||||
displayedFields: [],
|
||||
downloadLogs: () => {},
|
||||
enableLogDetails: false,
|
||||
filterLevels: [],
|
||||
hasUnescapedContent: false,
|
||||
setDedupStrategy: () => {},
|
||||
setDisplayedFields: () => {},
|
||||
setDetailsWidth: () => {},
|
||||
setFilterLevels: () => {},
|
||||
setForceEscape: () => {},
|
||||
setLogListState: () => {},
|
||||
@@ -22,9 +27,11 @@ export const LogListContext = createContext<LogListContextData>({
|
||||
setSortOrder: () => {},
|
||||
setSyntaxHighlighting: () => {},
|
||||
setWrapLogMessage: () => {},
|
||||
showDetails: [],
|
||||
showTime: true,
|
||||
sortOrder: LogsSortOrder.Ascending,
|
||||
syntaxHighlighting: true,
|
||||
toggleDetails: () => {},
|
||||
wrapLogMessage: false,
|
||||
});
|
||||
|
||||
@@ -42,21 +49,8 @@ export const useLogIsPinned = (log: LogRowModel) => {
|
||||
return pinnedLogs?.some((logId) => logId === log.rowId);
|
||||
};
|
||||
|
||||
export const defaultProps = {
|
||||
app: CoreApp.Explore,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
displayedFields: [],
|
||||
filterLevels: [],
|
||||
getRowContextQuery: jest.fn(),
|
||||
logSupportsContext: jest.fn(),
|
||||
logs: [],
|
||||
onPermalinkClick: jest.fn(),
|
||||
onPinLine: jest.fn(),
|
||||
onOpenContext: jest.fn(),
|
||||
onUnpinLine: jest.fn(),
|
||||
pinnedLogs: [],
|
||||
export const defaultValue: LogListContextData = {
|
||||
setDedupStrategy: jest.fn(),
|
||||
setDisplayedFields: jest.fn(),
|
||||
setFilterLevels: jest.fn(),
|
||||
setForceEscape: jest.fn(),
|
||||
setLogListState: jest.fn(),
|
||||
@@ -67,6 +61,38 @@ export const defaultProps = {
|
||||
setPrettifyJSON: jest.fn(),
|
||||
setSyntaxHighlighting: jest.fn(),
|
||||
setWrapLogMessage: jest.fn(),
|
||||
closeDetails: jest.fn(),
|
||||
detailsDisplayed: jest.fn(),
|
||||
detailsWidth: 0,
|
||||
downloadLogs: jest.fn(),
|
||||
enableLogDetails: false,
|
||||
filterLevels: [],
|
||||
setDetailsWidth: jest.fn(),
|
||||
showDetails: [],
|
||||
toggleDetails: jest.fn(),
|
||||
app: CoreApp.Explore,
|
||||
dedupStrategy: LogsDedupStrategy.exact,
|
||||
displayedFields: [],
|
||||
showTime: false,
|
||||
sortOrder: LogsSortOrder.Ascending,
|
||||
wrapLogMessage: false,
|
||||
};
|
||||
|
||||
export const defaultProps: Props = {
|
||||
app: CoreApp.Explore,
|
||||
containerElement: document.createElement('div'),
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
displayedFields: [],
|
||||
enableLogDetails: false,
|
||||
filterLevels: [],
|
||||
getRowContextQuery: jest.fn(),
|
||||
logSupportsContext: jest.fn(),
|
||||
logs: [],
|
||||
onPermalinkClick: jest.fn(),
|
||||
onPinLine: jest.fn(),
|
||||
onOpenContext: jest.fn(),
|
||||
onUnpinLine: jest.fn(),
|
||||
pinnedLogs: [],
|
||||
showControls: true,
|
||||
showTime: true,
|
||||
sortOrder: LogsSortOrder.Descending,
|
||||
@@ -79,6 +105,7 @@ export const LogListContextProvider = ({
|
||||
children,
|
||||
dedupStrategy = LogsDedupStrategy.none,
|
||||
displayedFields = [],
|
||||
enableLogDetails = false,
|
||||
filterLevels = [],
|
||||
getRowContextQuery = jest.fn(),
|
||||
logSupportsContext = jest.fn(),
|
||||
@@ -96,10 +123,12 @@ export const LogListContextProvider = ({
|
||||
return (
|
||||
<LogListContext.Provider
|
||||
value={{
|
||||
...defaultValue,
|
||||
app,
|
||||
dedupStrategy,
|
||||
displayedFields,
|
||||
downloadLogs: jest.fn(),
|
||||
enableLogDetails,
|
||||
filterLevels,
|
||||
getRowContextQuery,
|
||||
logSupportsContext,
|
||||
@@ -110,7 +139,6 @@ export const LogListContextProvider = ({
|
||||
onUnpinLine,
|
||||
pinnedLogs,
|
||||
setDedupStrategy: jest.fn(),
|
||||
setDisplayedFields: jest.fn(),
|
||||
setFilterLevels: jest.fn(),
|
||||
setForceEscape: jest.fn(),
|
||||
setLogListState: jest.fn(),
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('preProcessLogs', () => {
|
||||
container = document.createElement('div');
|
||||
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200);
|
||||
entry = new Array(2 * getTruncationLength(null)).fill('e').join('');
|
||||
longLog = createLogLine({ entry });
|
||||
longLog = createLogLine({ entry, labels: { field: 'value' } });
|
||||
});
|
||||
|
||||
test('Long lines that are not truncated are not modified', () => {
|
||||
@@ -178,6 +178,18 @@ describe('preProcessLogs', () => {
|
||||
expect(entry).toContain(longLog.body);
|
||||
});
|
||||
|
||||
test('Considers the displayed fields to set the collapsed state', () => {
|
||||
// Make container half of the size
|
||||
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(100);
|
||||
|
||||
expect(longLog.collapsed).toBeUndefined();
|
||||
|
||||
// Log line body is not included in the displayed fields, so it fits in the container
|
||||
longLog.updateCollapsedState(['field'], container);
|
||||
|
||||
expect(longLog.collapsed).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Updates the body based on the collapsed state', () => {
|
||||
expect(longLog.collapsed).toBeUndefined();
|
||||
expect(longLog.body).toBe(entry);
|
||||
|
||||
@@ -3,7 +3,7 @@ import Prism, { Grammar } from 'prismjs';
|
||||
import { DataFrame, dateTimeFormat, Labels, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data';
|
||||
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
||||
|
||||
import { escapeUnescapedString, sortLogRows } from '../../utils';
|
||||
import { checkLogsError, checkLogsSampled, escapeUnescapedString, sortLogRows } from '../../utils';
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { FieldDef, getAllFields } from '../logParser';
|
||||
|
||||
@@ -19,7 +19,9 @@ export class LogListModel implements LogRowModel {
|
||||
entry: string;
|
||||
entryFieldIndex: number;
|
||||
hasAnsi: boolean;
|
||||
hasError: boolean;
|
||||
hasUnescapedContent: boolean;
|
||||
isSampled: boolean;
|
||||
labels: Labels;
|
||||
logLevel: LogLevel;
|
||||
raw: string;
|
||||
@@ -49,7 +51,9 @@ export class LogListModel implements LogRowModel {
|
||||
this.entry = log.entry;
|
||||
this.entryFieldIndex = log.entryFieldIndex;
|
||||
this.hasAnsi = log.hasAnsi;
|
||||
this.hasError = !!checkLogsError(log);
|
||||
this.hasUnescapedContent = log.hasUnescapedContent;
|
||||
this.isSampled = !!checkLogsSampled(log);
|
||||
this.labels = log.labels;
|
||||
this.logLevel = log.logLevel;
|
||||
this.rowIndex = log.rowIndex;
|
||||
@@ -88,6 +92,10 @@ export class LogListModel implements LogRowModel {
|
||||
return this._body;
|
||||
}
|
||||
|
||||
get errorMessage(): string | undefined {
|
||||
return checkLogsError(this);
|
||||
}
|
||||
|
||||
get fields(): FieldDef[] {
|
||||
if (this._fields === undefined) {
|
||||
this._fields = getAllFields(this, this._getFieldLinks);
|
||||
@@ -103,6 +111,10 @@ export class LogListModel implements LogRowModel {
|
||||
return this._highlightedBody;
|
||||
}
|
||||
|
||||
get sampledMessage(): string | undefined {
|
||||
return checkLogsSampled(this);
|
||||
}
|
||||
|
||||
getDisplayedFieldValue(fieldName: string): string {
|
||||
if (fieldName === LOG_LINE_BODY_FIELD_NAME) {
|
||||
return this.body;
|
||||
@@ -119,7 +131,9 @@ export class LogListModel implements LogRowModel {
|
||||
|
||||
updateCollapsedState(displayedFields: string[], container: HTMLDivElement | null) {
|
||||
const lineLength =
|
||||
displayedFields.map((field) => this.getDisplayedFieldValue(field)).join('').length + this.raw.length;
|
||||
displayedFields.length > 0
|
||||
? displayedFields.map((field) => this.getDisplayedFieldValue(field)).join('').length
|
||||
: this.raw.length;
|
||||
const collapsed = lineLength >= getTruncationLength(container) ? true : undefined;
|
||||
if (this.collapsed === undefined || collapsed === undefined) {
|
||||
this.collapsed = collapsed;
|
||||
|
||||
@@ -13,6 +13,7 @@ const TWO_LINES_HEIGHT = 2 * LINE_HEIGHT + PADDING_BOTTOM;
|
||||
const THREE_LINES_HEIGHT = 3 * LINE_HEIGHT + PADDING_BOTTOM;
|
||||
let LETTER_WIDTH: number;
|
||||
let CONTAINER_SIZE = 200;
|
||||
let TWO_LINES_OF_CHARACTERS: number;
|
||||
|
||||
describe('Virtualization', () => {
|
||||
let log: LogListModel, container: HTMLDivElement;
|
||||
@@ -22,11 +23,12 @@ describe('Virtualization', () => {
|
||||
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE);
|
||||
init(createTheme());
|
||||
LETTER_WIDTH = measureTextWidth('e');
|
||||
TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
|
||||
});
|
||||
|
||||
describe('getLogLineSize', () => {
|
||||
test('Returns the a single line if the display mode is unwrapped', () => {
|
||||
const size = getLogLineSize([log], container, [], { wrap: false, showControls: true, showTime: true }, 0);
|
||||
const size = getLogLineSize([log], container, [], { wrap: false, showTime: true, showDuplicates: false }, 0);
|
||||
expect(size).toBe(SINGLE_LINE_HEIGHT);
|
||||
});
|
||||
|
||||
@@ -36,7 +38,7 @@ describe('Virtualization', () => {
|
||||
logs,
|
||||
container,
|
||||
[],
|
||||
{ wrap: true, showControls: true, showTime: true },
|
||||
{ wrap: true, showTime: true, showDuplicates: false },
|
||||
logs.length + 1
|
||||
);
|
||||
expect(size).toBe(SINGLE_LINE_HEIGHT);
|
||||
@@ -46,43 +48,35 @@ describe('Virtualization', () => {
|
||||
// Very small container
|
||||
log.collapsed = true;
|
||||
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10);
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showControls: true, showTime: true }, 0);
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showTime: true, showDuplicates: false }, 0);
|
||||
expect(size).toBe((TRUNCATION_LINE_COUNT + 1) * LINE_HEIGHT);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[false, false],
|
||||
[true, false],
|
||||
[false, true],
|
||||
[true, true],
|
||||
])('Measures a log line with controls %s and displayed time %s', (showControls: boolean, showTime: boolean) => {
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showControls, showTime }, 0);
|
||||
test.each([true, false])('Measures a log line with controls %s and displayed time %s', (showTime: boolean) => {
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showTime, showDuplicates: false }, 0);
|
||||
expect(size).toBe(SINGLE_LINE_HEIGHT);
|
||||
});
|
||||
|
||||
test('Measures a multi-line log line with no controls and no displayed time', () => {
|
||||
const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
|
||||
test('Measures a multi-line log line with no displayed time', () => {
|
||||
log = createLogLine({
|
||||
labels: { place: 'luna' },
|
||||
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
|
||||
logLevel: undefined,
|
||||
});
|
||||
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showControls: false, showTime: false }, 0);
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0);
|
||||
expect(size).toBe(TWO_LINES_HEIGHT);
|
||||
});
|
||||
|
||||
test('Measures a multi-line log line with level, controls, and displayed time', () => {
|
||||
const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
|
||||
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
|
||||
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showControls: true, showTime: true }, 0);
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showTime: true, showDuplicates: false }, 0);
|
||||
// Two lines for the log and one extra for level and time
|
||||
expect(size).toBe(THREE_LINES_HEIGHT);
|
||||
});
|
||||
|
||||
test('Measures a multi-line log line with displayed fields', () => {
|
||||
const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
|
||||
log = createLogLine({
|
||||
labels: { place: 'very very long value for the displayed field that causes a new line' },
|
||||
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
|
||||
@@ -93,7 +87,7 @@ describe('Virtualization', () => {
|
||||
[log],
|
||||
container,
|
||||
['place', LOG_LINE_BODY_FIELD_NAME],
|
||||
{ wrap: true, showControls: false, showTime: false },
|
||||
{ wrap: true, showTime: false, showDuplicates: false },
|
||||
0
|
||||
);
|
||||
// Two lines for the log and one extra for the displayed fields
|
||||
@@ -101,18 +95,50 @@ describe('Virtualization', () => {
|
||||
});
|
||||
|
||||
test('Measures displayed fields in a log line with level, controls, and displayed time', () => {
|
||||
const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 2;
|
||||
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
|
||||
|
||||
const size = getLogLineSize([log], container, ['place'], { wrap: true, showControls: true, showTime: true }, 0);
|
||||
const size = getLogLineSize(
|
||||
[log],
|
||||
container,
|
||||
['place'],
|
||||
{ wrap: true, showTime: true, showDuplicates: false },
|
||||
0
|
||||
);
|
||||
// Only renders a short displayed field, so a single line
|
||||
expect(size).toBe(SINGLE_LINE_HEIGHT);
|
||||
});
|
||||
|
||||
test('Measures a multi-line log line with duplicates', () => {
|
||||
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
|
||||
log.duplicates = 1;
|
||||
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: true }, 0);
|
||||
// Two lines for the log and one extra for duplicates
|
||||
expect(size).toBe(THREE_LINES_HEIGHT);
|
||||
});
|
||||
|
||||
test('Measures a multi-line log line with errors', () => {
|
||||
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
|
||||
log.hasError = true;
|
||||
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0);
|
||||
// Two lines for the log and one extra for the error icon
|
||||
expect(size).toBe(THREE_LINES_HEIGHT);
|
||||
});
|
||||
|
||||
test('Measures a multi-line sampled log line', () => {
|
||||
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
|
||||
log.isSampled = true;
|
||||
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0);
|
||||
// Two lines for the log and one extra for the sampled icon
|
||||
expect(size).toBe(THREE_LINES_HEIGHT);
|
||||
});
|
||||
|
||||
test('Adds an extra line for the expand/collapse controls if present', () => {
|
||||
jest.spyOn(log, 'updateCollapsedState').mockImplementation(() => undefined);
|
||||
log.collapsed = false;
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showControls: false, showTime: false }, 0);
|
||||
const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0);
|
||||
expect(size).toBe(TWO_LINES_HEIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ 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;
|
||||
const LOG_LIST_NAVIGATION_WIDTH = 28;
|
||||
|
||||
export const getLineHeight = () => lineHeight;
|
||||
|
||||
@@ -148,16 +147,16 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth =
|
||||
}
|
||||
|
||||
interface DisplayOptions {
|
||||
wrap: boolean;
|
||||
showControls: boolean;
|
||||
showDuplicates: boolean;
|
||||
showTime: boolean;
|
||||
wrap: boolean;
|
||||
}
|
||||
|
||||
export function getLogLineSize(
|
||||
logs: LogListModel[],
|
||||
container: HTMLDivElement | null,
|
||||
displayedFields: string[],
|
||||
{ wrap, showControls, showTime }: DisplayOptions,
|
||||
{ showDuplicates, showTime, wrap }: DisplayOptions,
|
||||
index: number
|
||||
) {
|
||||
if (!container) {
|
||||
@@ -167,7 +166,6 @@ export function getLogLineSize(
|
||||
if (!wrap || !logs[index]) {
|
||||
return lineHeight + paddingBottom;
|
||||
}
|
||||
|
||||
// If a long line is collapsed, we show the line count + an extra line for the expand/collapse control
|
||||
logs[index].updateCollapsedState(displayedFields, container);
|
||||
if (logs[index].collapsed) {
|
||||
@@ -182,8 +180,14 @@ export function getLogLineSize(
|
||||
let textToMeasure = '';
|
||||
const gap = gridSize * FIELD_GAP_MULTIPLIER;
|
||||
let optionsWidth = 0;
|
||||
if (showControls) {
|
||||
optionsWidth += LOG_LIST_NAVIGATION_WIDTH;
|
||||
if (showDuplicates) {
|
||||
optionsWidth += gridSize * 4.5 + gap;
|
||||
}
|
||||
if (logs[index].hasError) {
|
||||
optionsWidth += gridSize * 2 + gap;
|
||||
}
|
||||
if (logs[index].isSampled) {
|
||||
optionsWidth += gridSize * 2 + gap;
|
||||
}
|
||||
if (showTime) {
|
||||
optionsWidth += gap;
|
||||
|
||||
@@ -115,7 +115,7 @@ beforeAll(() => {
|
||||
describe('LogsPanel', () => {
|
||||
test('Renders a list of logs without controls ', async () => {
|
||||
setup();
|
||||
expect(await screen.findByText('logline text')).toBeInTheDocument();
|
||||
await screen.findByText('logline text');
|
||||
expect(screen.queryByLabelText('Scroll to bottom')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Display levels')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Scroll to top')).not.toBeInTheDocument();
|
||||
@@ -123,7 +123,7 @@ describe('LogsPanel', () => {
|
||||
|
||||
test('Renders a list of logs with controls', async () => {
|
||||
setup({ options: { ...defaultProps.options, showControls: true } });
|
||||
expect(await screen.findByText('logline text')).toBeInTheDocument();
|
||||
await screen.findByText('logline text');
|
||||
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Display levels')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument();
|
||||
|
||||
@@ -32,6 +32,7 @@ export const LogsPanel = ({
|
||||
timeZone,
|
||||
fieldConfig,
|
||||
options: {
|
||||
controlsStorageKey,
|
||||
dedupStrategy,
|
||||
enableInfiniteScrolling,
|
||||
grammar,
|
||||
@@ -129,6 +130,16 @@ export const LogsPanel = ({
|
||||
return 'top';
|
||||
}, [data.request?.app, sortOrder]);
|
||||
|
||||
const storageKey = useMemo(() => {
|
||||
if (controlsStorageKey) {
|
||||
return controlsStorageKey;
|
||||
}
|
||||
if (!data.request) {
|
||||
return undefined;
|
||||
}
|
||||
return `${data.request?.dashboardUID}.${id}`;
|
||||
}, [controlsStorageKey, data.request, id]);
|
||||
|
||||
if (!logs.length) {
|
||||
return <PanelDataErrorView fieldConfig={fieldConfig} panelId={id} data={data} needsStringField />;
|
||||
}
|
||||
@@ -141,6 +152,7 @@ export const LogsPanel = ({
|
||||
containerElement={logsContainer}
|
||||
dedupStrategy={dedupStrategy}
|
||||
displayedFields={[]}
|
||||
enableLogDetails
|
||||
grammar={isLogsGrammar(grammar) ? grammar : undefined}
|
||||
initialScrollPosition={initialScrollPosition}
|
||||
logs={logs}
|
||||
@@ -150,6 +162,7 @@ export const LogsPanel = ({
|
||||
showControls={showControls}
|
||||
showTime={showTime}
|
||||
sortOrder={sortOrder}
|
||||
logOptionsStorageKey={storageKey}
|
||||
syntaxHighlighting={syntaxHighlighting}
|
||||
timeRange={data.timeRange}
|
||||
timeZone={timeZone}
|
||||
|
||||
@@ -37,6 +37,7 @@ composableKinds: PanelCfg: {
|
||||
enableInfiniteScrolling?: bool
|
||||
onLogOptionsChange?: _
|
||||
onNewLogsReceived?: _
|
||||
controlsStorageKey?: string
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
}]
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import * as common from '@grafana/schema';
|
||||
|
||||
export interface Options {
|
||||
controlsStorageKey?: string;
|
||||
dedupStrategy: common.LogsDedupStrategy;
|
||||
enableInfiniteScrolling?: boolean;
|
||||
enableLogDetails: boolean;
|
||||
|
||||
@@ -6148,6 +6148,7 @@
|
||||
"label-wrap-lines": "Wrap lines"
|
||||
},
|
||||
"log-details": {
|
||||
"close": "Close log details",
|
||||
"fields": "Fields",
|
||||
"links": "Links",
|
||||
"log-line": "Log line",
|
||||
@@ -6167,15 +6168,20 @@
|
||||
"log-line": "log line"
|
||||
},
|
||||
"log-line": {
|
||||
"has-error": "Has errors",
|
||||
"is-sampled": "Is sampled",
|
||||
"show-less": "show less",
|
||||
"show-more": "show more"
|
||||
"show-more": "show more",
|
||||
"tooltip-error": "Error: {{errorMessage}}"
|
||||
},
|
||||
"log-line-menu": {
|
||||
"copy-link": "Copy link to log line",
|
||||
"copy-log": "Copy log line",
|
||||
"hide-details": "Show log details",
|
||||
"icon-label": "Log menu",
|
||||
"pin-to-outline": "Pin log",
|
||||
"show-context": "Show context",
|
||||
"show-details": "Hide log details",
|
||||
"unpin-from-outline": "Unpin log"
|
||||
},
|
||||
"log-row-context-modal": {
|
||||
|
||||
Reference in New Issue
Block a user