New Logs Panel: Add LogListControl component (#102359)
* LogListNavigation: create component * LogListNavigation: make it optional * Refactor Navigation into Controls * Explore Logs: hide old panel options * LogListControls: add more controls and apply new styles * LogListControls: update width * Remove console log * LogList: wrap component in context * LogListContext: make it stateful * LogListControls: connect with context * LogListContext: sync displayed fields * LogListContext: add syntax highlighting control * LogLine: improve color with highlighting off * LogListControls: improve button styles * LogListControls: add sort order option * LogListControls: add dedup strategy * LogListContext: sync prop changes with state * LogList: add option change callback * LogListContext: sync external changes only when showControls is disabled * LogListContext: unify sync effect * LogListControls: improve buttons active css * LogListContext: add optional storage support * LogList: make syntaxHighlighting and read from store or default to true * LogsPanel: add support for new options * Generated file * Update tests * New Logs Panel: add showControls option * LogList: make eventBus optional * LogsPanel: expose syntaxHighlighting * LogsPanel: expose grammar to plugins * Remove incorrect legacy file * LogListControls: allow options change with controls enabled in panel editor * LogLine: apply no-highlighting class to ansi wrapper * LogListControls: add frontend level filtering * LogList: filter by filterLevels * LogListControls: allow filterLevels in dashboards * LogLine: update unit tests * LogLineMenu: update unit test * LogListControls: add unit test * Revert * LogList: add unit test * LogList: add onLogRowHover support * LogList: test hover events * LogsPanel: add unit test * LogLine: use theme color for errors * LogLine: minor tweak to hover state * Chore: fix test case name * LogListControls: add border * LogListControls: prevent default on wrap toggle click * LogLine: switch info and debug colors * ContentOutline + LogControls: sync displayed levels and minor outline improvements * LogListContext: sync with external filterLevel changes * Logs: work with arrays of LogLevel and not plain strings * Fix type * LogListControl: increase controls size
This commit is contained in:
-23
@@ -1,23 +0,0 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// public/app/plugins/gen.go
|
||||
// Using jennies:
|
||||
// TSTypesJenny
|
||||
// PluginTsTypesJenny
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
import * as common from '@grafana/schema';
|
||||
|
||||
export const pluginVersion = "11.6.0-pre";
|
||||
|
||||
export interface Options {
|
||||
dedupStrategy: common.LogsDedupStrategy;
|
||||
enableInfiniteScrolling?: boolean;
|
||||
enableLogDetails: boolean;
|
||||
onNewLogsReceived?: unknown;
|
||||
showTime: boolean;
|
||||
sortOrder: common.LogsSortOrder;
|
||||
wrapLogMessage: boolean;
|
||||
}
|
||||
+4
@@ -16,8 +16,12 @@ export interface Options {
|
||||
dedupStrategy: common.LogsDedupStrategy;
|
||||
enableInfiniteScrolling?: boolean;
|
||||
enableLogDetails: boolean;
|
||||
grammar?: unknown;
|
||||
onLogOptionsChange?: unknown;
|
||||
onNewLogsReceived?: unknown;
|
||||
showControls: boolean;
|
||||
showTime: boolean;
|
||||
sortOrder: common.LogsSortOrder;
|
||||
syntaxHighlighting: boolean;
|
||||
wrapLogMessage: boolean;
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ export function contentOutlineTrackUnpinClicked() {
|
||||
});
|
||||
}
|
||||
|
||||
export function contentOutlineTrackLevelFilter(level: { levelStr: string; logLevel: LogLevel }) {
|
||||
export function contentOutlineTrackLevelFilter(level: LogLevel) {
|
||||
reportInteraction('explore_toolbar_contentoutline_clicked', {
|
||||
item: 'section',
|
||||
type: `Logs:filter:${level.levelStr}`,
|
||||
type: `Logs:filter:${level}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,10 +55,9 @@ import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/sh
|
||||
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
|
||||
import { LogRows } from 'app/features/logs/components/LogRows';
|
||||
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';
|
||||
import { LogList } from 'app/features/logs/components/panel/LogList';
|
||||
import { ScrollToLogsEvent } from 'app/features/logs/components/panel/virtualization';
|
||||
import { LogList, LogListControlOptions } from 'app/features/logs/components/panel/LogList';
|
||||
import { LogLevelColor, dedupLogRows, filterLogLevels } from 'app/features/logs/logsModel';
|
||||
import { getLogLevel, getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils';
|
||||
import { getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils';
|
||||
import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen';
|
||||
import { isLokiQuery } from 'app/plugins/datasource/loki/queryUtils';
|
||||
import { getState } from 'app/store/store';
|
||||
@@ -82,7 +81,7 @@ import { LogsMetaRow } from './LogsMetaRow';
|
||||
import LogsNavigation from './LogsNavigation';
|
||||
import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap';
|
||||
import { LogsVolumePanelList } from './LogsVolumePanelList';
|
||||
import { SETTINGS_KEYS, visualisationTypeKey } from './utils/logs';
|
||||
import { SETTING_KEY_ROOT, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs';
|
||||
|
||||
interface Props extends Themeable2 {
|
||||
width: number;
|
||||
@@ -225,6 +224,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
const cancelFlippingTimer = useRef<number | undefined>(undefined);
|
||||
const toggleLegendRef = useRef<(name: string, mode: SeriesVisibilityChangeMode) => void>(() => {});
|
||||
const topLogsRef = useRef<HTMLDivElement>(null);
|
||||
const logLevelsRef = useRef<LogLevel[] | null>(null);
|
||||
|
||||
const tableHeight = getLogsTableHeight();
|
||||
const styles = getStyles(theme, wrapLogMessage, tableHeight);
|
||||
@@ -265,38 +265,37 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
}
|
||||
|
||||
// check if we have dataFrames that return the same level
|
||||
const logLevelsArray: Array<{ levelStr: string; logLevel: LogLevel }> = [];
|
||||
const logLevelsArray: LogLevel[] = [];
|
||||
logVolumeDataFrames.forEach((dataFrame) => {
|
||||
const { level } = getLogLevelInfo(dataFrame, logsVolumeData?.data ?? []);
|
||||
logLevelsArray.push({ levelStr: level, logLevel: getLogLevel(level) });
|
||||
logLevelsArray.push(getLogLevelFromKey(level));
|
||||
});
|
||||
|
||||
const sortedLLArray = logLevelsArray.sort(
|
||||
(a: { levelStr: string; logLevel: LogLevel }, b: { levelStr: string; logLevel: LogLevel }) => {
|
||||
return levelsArr.indexOf(a.logLevel.toString()) > levelsArr.indexOf(b.logLevel.toString()) ? 1 : -1;
|
||||
}
|
||||
const sortedLLArray = logLevelsArray.sort((a: string, b: string) =>
|
||||
levelsArr.indexOf(a) > levelsArr.indexOf(b) ? 1 : -1
|
||||
);
|
||||
|
||||
const logLevels = new Set(sortedLLArray);
|
||||
logLevelsRef.current = Array.from(logLevels);
|
||||
|
||||
if (logLevels.size > 1 && logsVolumeEnabled && numberOfLogVolumes === 1) {
|
||||
logLevels.forEach((level) => {
|
||||
const allLevelsSelected = hiddenLogLevels.length === 0;
|
||||
const currentLevelSelected = !hiddenLogLevels.find((hiddenLevel) => hiddenLevel === level.levelStr);
|
||||
const currentLevelSelected = !hiddenLogLevels.find((hiddenLevel) => hiddenLevel === level);
|
||||
if (register) {
|
||||
register({
|
||||
title: level.levelStr,
|
||||
title: level,
|
||||
icon: 'gf-logs',
|
||||
panelId: PINNED_LOGS_PANELID,
|
||||
level: 'child',
|
||||
type: 'filter',
|
||||
highlight: currentLevelSelected && !allLevelsSelected,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
toggleLegendRef.current?.(level.levelStr, mapMouseEventToMode(e));
|
||||
toggleLegendRef.current?.(level, mapMouseEventToMode(e));
|
||||
contentOutlineTrackLevelFilter(level);
|
||||
},
|
||||
ref: null,
|
||||
color: LogLevelColor[level.logLevel],
|
||||
color: LogLevelColor[level],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -450,42 +449,50 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
[scrollElement]
|
||||
);
|
||||
|
||||
const onChangeLogsSortOrder = () => {
|
||||
setIsFlipping(true);
|
||||
// we are using setTimeout here to make sure that disabled button is rendered before the rendering of reordered logs
|
||||
flipOrderTimer.current = window.setTimeout(() => {
|
||||
const newSortOrder =
|
||||
logsSortOrder === LogsSortOrder.Descending ? LogsSortOrder.Ascending : LogsSortOrder.Descending;
|
||||
store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder);
|
||||
if (logsQueries) {
|
||||
let hasLokiQueries = false;
|
||||
const newQueries = logsQueries.map((query) => {
|
||||
if (query.datasource?.type !== 'loki' || !isLokiQuery(query)) {
|
||||
return query;
|
||||
}
|
||||
if (query.direction === LokiQueryDirection.Scan) {
|
||||
// Don't override Scan. When the direction is Scan it means that the user specifically assigned this direction to the query.
|
||||
return query;
|
||||
}
|
||||
hasLokiQueries = true;
|
||||
const newDirection =
|
||||
newSortOrder === LogsSortOrder.Ascending ? LokiQueryDirection.Forward : LokiQueryDirection.Backward;
|
||||
if (newDirection !== query.direction) {
|
||||
query.direction = newDirection;
|
||||
}
|
||||
return query;
|
||||
});
|
||||
|
||||
if (hasLokiQueries) {
|
||||
dispatch(changeQueries({ exploreId, queries: newQueries }));
|
||||
dispatch(runQueries({ exploreId }));
|
||||
}
|
||||
const sortOrderChanged = useCallback(
|
||||
(newSortOrder: LogsSortOrder) => {
|
||||
if (!logsQueries) {
|
||||
return;
|
||||
}
|
||||
let hasLokiQueries = false;
|
||||
const newQueries = logsQueries.map((query) => {
|
||||
if (query.datasource?.type !== 'loki' || !isLokiQuery(query)) {
|
||||
return query;
|
||||
}
|
||||
if (query.direction === LokiQueryDirection.Scan) {
|
||||
// Don't override Scan. When the direction is Scan it means that the user specifically assigned this direction to the query.
|
||||
return query;
|
||||
}
|
||||
hasLokiQueries = true;
|
||||
const newDirection =
|
||||
newSortOrder === LogsSortOrder.Ascending ? LokiQueryDirection.Forward : LokiQueryDirection.Backward;
|
||||
if (newDirection !== query.direction) {
|
||||
query.direction = newDirection;
|
||||
}
|
||||
return query;
|
||||
});
|
||||
|
||||
setLogsSortOrder(newSortOrder);
|
||||
}, 0);
|
||||
cancelFlippingTimer.current = window.setTimeout(() => setIsFlipping(false), 1000);
|
||||
};
|
||||
if (hasLokiQueries) {
|
||||
dispatch(changeQueries({ exploreId, queries: newQueries }));
|
||||
dispatch(runQueries({ exploreId }));
|
||||
}
|
||||
},
|
||||
[dispatch, exploreId, logsQueries]
|
||||
);
|
||||
|
||||
const onChangeLogsSortOrder = useCallback(
|
||||
(newSortOrder: LogsSortOrder) => {
|
||||
setIsFlipping(true);
|
||||
// we are using setTimeout here to make sure that disabled button is rendered before the rendering of reordered logs
|
||||
flipOrderTimer.current = window.setTimeout(() => {
|
||||
store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder);
|
||||
sortOrderChanged(newSortOrder);
|
||||
setLogsSortOrder(newSortOrder);
|
||||
}, 0);
|
||||
cancelFlippingTimer.current = window.setTimeout(() => setIsFlipping(false), 1000);
|
||||
},
|
||||
[sortOrderChanged]
|
||||
);
|
||||
|
||||
const onEscapeNewlines = useCallback(() => {
|
||||
setForceEscape(!forceEscape);
|
||||
@@ -561,7 +568,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
}, []);
|
||||
|
||||
const onToggleLogLevel = useCallback((hiddenRawLevels: string[]) => {
|
||||
const hiddenLogLevels = hiddenRawLevels.map((level) => getLogLevelFromKey(level));
|
||||
const hiddenLogLevels = hiddenRawLevels.map(getLogLevelFromKey);
|
||||
setHiddenLogLevels(hiddenLogLevels);
|
||||
}, []);
|
||||
|
||||
@@ -696,13 +703,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
);
|
||||
|
||||
const scrollToTopLogs = useCallback(() => {
|
||||
if (config.featureToggles.newLogsPanel) {
|
||||
eventBus.publish(
|
||||
new ScrollToLogsEvent({
|
||||
scrollTo: 'top',
|
||||
})
|
||||
);
|
||||
} else if (config.featureToggles.logsInfiniteScrolling) {
|
||||
if (config.featureToggles.logsInfiniteScrolling) {
|
||||
if (logsContainerRef.current) {
|
||||
logsContainerRef.current.scroll({
|
||||
behavior: 'auto',
|
||||
@@ -711,25 +712,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
}
|
||||
}
|
||||
topLogsRef.current?.scrollIntoView();
|
||||
}, [eventBus]);
|
||||
|
||||
const scrollToBottomLogs = useCallback(() => {
|
||||
if (config.featureToggles.newLogsPanel) {
|
||||
eventBus.publish(
|
||||
new ScrollToLogsEvent({
|
||||
scrollTo: 'bottom',
|
||||
})
|
||||
);
|
||||
} else if (config.featureToggles.logsInfiniteScrolling) {
|
||||
if (logsContainerRef.current) {
|
||||
logsContainerRef.current.scroll({
|
||||
behavior: 'auto',
|
||||
top: logsContainerRef.current.scrollHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
topLogsRef.current?.scrollTo(0, topLogsRef.current.scrollHeight);
|
||||
}, [eventBus]);
|
||||
}, []);
|
||||
|
||||
const onPinToContentOutlineClick = useCallback(
|
||||
(row: LogRowModel, allowUnPin = true) => {
|
||||
@@ -789,6 +772,44 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
[logsQueries]
|
||||
);
|
||||
|
||||
const onLogOptionsChange = useCallback(
|
||||
(option: keyof LogListControlOptions, value: string | string[] | boolean) => {
|
||||
if (option === 'sortOrder' && (value === LogsSortOrder.Ascending || value === LogsSortOrder.Descending)) {
|
||||
sortOrderChanged(value);
|
||||
} else if (option === 'filterLevels' && Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
setHiddenLogLevels([]);
|
||||
return;
|
||||
}
|
||||
const allLevels = logLevelsRef.current ?? Object.keys(LogLevelColor).map(getLogLevelFromKey);
|
||||
if (hiddenLogLevels.length === 0) {
|
||||
toggleLegendRef.current?.(value[0], SeriesVisibilityChangeMode.ToggleSelection);
|
||||
setHiddenLogLevels(allLevels.filter((level) => level !== value[0]));
|
||||
return;
|
||||
}
|
||||
const appendsLevel = value.find((level) => hiddenLogLevels.includes(getLogLevelFromKey(level)));
|
||||
const removesLevel = allLevels.find((level) => !value.includes(level) && !hiddenLogLevels.includes(level));
|
||||
if (appendsLevel) {
|
||||
toggleLegendRef.current?.(appendsLevel, SeriesVisibilityChangeMode.AppendToSelection);
|
||||
setHiddenLogLevels(hiddenLogLevels.filter((hiddenLevel) => hiddenLevel === appendsLevel));
|
||||
return;
|
||||
} else if (removesLevel) {
|
||||
toggleLegendRef.current?.(removesLevel, SeriesVisibilityChangeMode.AppendToSelection);
|
||||
setHiddenLogLevels([...hiddenLogLevels, removesLevel]);
|
||||
}
|
||||
}
|
||||
},
|
||||
[hiddenLogLevels, sortOrderChanged]
|
||||
);
|
||||
|
||||
const filterLevels: LogLevel[] | undefined = useMemo(
|
||||
() =>
|
||||
!logLevelsRef.current
|
||||
? undefined
|
||||
: logLevelsRef.current.filter((level) => hiddenLogLevels.length > 0 && !hiddenLogLevels.includes(level)),
|
||||
[hiddenLogLevels]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{getRowContext && contextRow && (
|
||||
@@ -865,7 +886,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
loadingState={loading ? LoadingState.Loading : LoadingState.Done}
|
||||
>
|
||||
<div className={styles.stickyNavigation}>
|
||||
{visualisationType !== 'table' && (
|
||||
{visualisationType !== 'table' && !config.featureToggles.newLogsPanel && (
|
||||
<div className={styles.logOptions}>
|
||||
<InlineFieldRow>
|
||||
<InlineField
|
||||
@@ -1069,48 +1090,38 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
</>
|
||||
)}
|
||||
{visualisationType === 'logs' && hasData && config.featureToggles.newLogsPanel && (
|
||||
<>
|
||||
<div data-testid="logRows" ref={logsContainerRef} className={styles.logRowsWrapper}>
|
||||
{logsContainerRef.current && (
|
||||
<LogList
|
||||
app={CoreApp.Explore}
|
||||
logSupportsContext={showContextToggle}
|
||||
containerElement={logsContainerRef.current}
|
||||
displayedFields={displayedFields}
|
||||
eventBus={eventBus}
|
||||
forceEscape={forceEscape}
|
||||
getFieldLinks={getFieldLinks}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
loadMore={loadMoreLogs}
|
||||
logs={dedupedRows}
|
||||
onOpenContext={onOpenContext}
|
||||
onPermalinkClick={onPermalinkClick}
|
||||
onPinLine={onPinToContentOutlineClick}
|
||||
onUnpinLine={onPinToContentOutlineClick}
|
||||
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
|
||||
pinnedLogs={pinnedLogs}
|
||||
showTime={showTime}
|
||||
sortOrder={logsSortOrder}
|
||||
timeRange={props.range}
|
||||
timeZone={timeZone}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<LogsNavigation
|
||||
logsSortOrder={logsSortOrder}
|
||||
visibleRange={navigationRange ?? absoluteRange}
|
||||
absoluteRange={absoluteRange}
|
||||
timeZone={timeZone}
|
||||
onChangeTime={onChangeTime}
|
||||
loading={loading}
|
||||
queries={logsQueries ?? []}
|
||||
scrollToTopLogs={scrollToTopLogs}
|
||||
scrollToBottomLogs={scrollToBottomLogs}
|
||||
addResultsToCache={addResultsToCache}
|
||||
clearCache={clearCache}
|
||||
/>
|
||||
</>
|
||||
<div data-testid="logRows" ref={logsContainerRef} className={styles.logRowsWrapper}>
|
||||
{logsContainerRef.current && (
|
||||
<LogList
|
||||
app={CoreApp.Explore}
|
||||
containerElement={logsContainerRef.current}
|
||||
dedupStrategy={dedupStrategy}
|
||||
displayedFields={displayedFields}
|
||||
filterLevels={filterLevels}
|
||||
forceEscape={forceEscape}
|
||||
getFieldLinks={getFieldLinks}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
loadMore={loadMoreLogs}
|
||||
logOptionsStorageKey={SETTING_KEY_ROOT}
|
||||
logs={dedupedRows}
|
||||
logSupportsContext={showContextToggle}
|
||||
onLogOptionsChange={onLogOptionsChange}
|
||||
onLogLineHover={onLogRowHover}
|
||||
onOpenContext={onOpenContext}
|
||||
onPermalinkClick={onPermalinkClick}
|
||||
onPinLine={onPinToContentOutlineClick}
|
||||
onUnpinLine={onPinToContentOutlineClick}
|
||||
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
|
||||
pinnedLogs={pinnedLogs}
|
||||
showControls
|
||||
showTime={showTime}
|
||||
sortOrder={logsSortOrder}
|
||||
timeRange={props.range}
|
||||
timeZone={timeZone}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !hasData && !scanning && (
|
||||
<div className={styles.noDataWrapper}>
|
||||
@@ -1226,7 +1237,7 @@ const dedupRows = (logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) =>
|
||||
return { dedupedRows, dedupCount };
|
||||
};
|
||||
|
||||
const filterRows = (logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => {
|
||||
const filterRows = (logRows: LogRowModel[], hiddenLogLevels: string[]) => {
|
||||
return filterLogLevels(logRows, new Set(hiddenLogLevels));
|
||||
};
|
||||
|
||||
|
||||
@@ -7,4 +7,6 @@ export const SETTINGS_KEYS = {
|
||||
logContextWrapLogMessage: 'grafana.explore.logs.logContext.wrapLogMessage',
|
||||
};
|
||||
|
||||
export const SETTING_KEY_ROOT = 'grafana.explore.logs';
|
||||
|
||||
export const visualisationTypeKey = 'grafana.explore.logs.visualisationType';
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { createTheme } from '@grafana/data';
|
||||
import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { createLogLine } from '../__mocks__/logRow';
|
||||
|
||||
import { getStyles, LogLine } from './LogLine';
|
||||
import { LogListContextProvider } from './LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
jest.mock('./LogListContext');
|
||||
|
||||
const theme = createTheme();
|
||||
const styles = getStyles(theme);
|
||||
const contextProps = {
|
||||
app: CoreApp.Unknown,
|
||||
dedupStrategy: LogsDedupStrategy.exact,
|
||||
displayedFields: [],
|
||||
showControls: false,
|
||||
showTime: false,
|
||||
sortOrder: LogsSortOrder.Ascending,
|
||||
wrapLogMessage: false,
|
||||
};
|
||||
|
||||
describe('LogLine', () => {
|
||||
let log: LogListModel;
|
||||
@@ -102,4 +114,69 @@ describe('LogLine', () => {
|
||||
expect(screen.getByText('Copy log line')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Syntax highlighting', () => {
|
||||
beforeEach(() => {
|
||||
log = createLogLine({ labels: { place: 'luna' }, entry: `place="luna" 1ms 3 KB` });
|
||||
});
|
||||
|
||||
test('Highlights relevant tokens in the log line', () => {
|
||||
render(
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('place')).toBeInTheDocument();
|
||||
expect(screen.getByText('1ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 KB')).toBeInTheDocument();
|
||||
expect(screen.queryByText(`place="luna" 1ms 3 KB`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Can be disabled', () => {
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} syntaxHighlighting={false}>
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.getByText(`place="luna" 1ms 3 KB`)).toBeInTheDocument();
|
||||
expect(screen.queryByText('place')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('1ms')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('3 KB')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Does not alter ANSI log lines', () => {
|
||||
log = createLogLine({ labels: { place: 'luna' }, entry: 'Lorem \u001B[31mipsum\u001B[0m et dolor' });
|
||||
log.hasAnsi = true;
|
||||
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} syntaxHighlighting={false}>
|
||||
<LogLine
|
||||
displayedFields={[]}
|
||||
index={0}
|
||||
log={log}
|
||||
showTime={true}
|
||||
style={{}}
|
||||
styles={styles}
|
||||
wrapLogMessage={false}
|
||||
/>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.getByTestId('ansiLogLine')).toBeInTheDocument();
|
||||
expect(screen.queryByText(log.entry)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { CSSProperties, useEffect, useRef } from 'react';
|
||||
import { CSSProperties, useCallback, useEffect, useRef } from 'react';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@@ -8,7 +8,7 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { LogMessageAnsi } from '../LogMessageAnsi';
|
||||
|
||||
import { LogLineMenu } from './LogLineMenu';
|
||||
import { useLogIsPinned } from './LogListContext';
|
||||
import { useLogIsPinned, useLogListContext } from './LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
import { FIELD_GAP_MULTIPLIER, hasUnderOrOverflow, getLineHeight, LogFieldDimension } from './virtualization';
|
||||
|
||||
@@ -35,9 +35,14 @@ export const LogLine = ({
|
||||
variant,
|
||||
wrapLogMessage,
|
||||
}: Props) => {
|
||||
const { onLogLineHover } = useLogListContext();
|
||||
const logLineRef = useRef<HTMLDivElement | null>(null);
|
||||
const pinned = useLogIsPinned(log);
|
||||
|
||||
const handleMouseOver = useCallback(() => {
|
||||
onLogLineHover?.(log);
|
||||
}, [log, onLogLineHover]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onOverflow || !logLineRef.current) {
|
||||
return;
|
||||
@@ -54,6 +59,7 @@ export const LogLine = ({
|
||||
style={style}
|
||||
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`}
|
||||
ref={onOverflow ? logLineRef : undefined}
|
||||
onMouseOver={handleMouseOver}
|
||||
>
|
||||
<LogLineMenu styles={styles} log={log} />
|
||||
<div className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`}`}>
|
||||
@@ -105,17 +111,23 @@ const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProp
|
||||
};
|
||||
|
||||
const LogLineBody = ({ log }: { log: LogListModel }) => {
|
||||
const { syntaxHighlighting } = useLogListContext();
|
||||
|
||||
if (log.hasAnsi) {
|
||||
const needsHighlighter =
|
||||
log.searchWords && log.searchWords.length > 0 && log.searchWords[0] && log.searchWords[0].length > 0;
|
||||
const highlight = needsHighlighter ? { searchWords: log.searchWords ?? [], highlightClassName: '' } : undefined;
|
||||
return (
|
||||
<span className="field">
|
||||
<span className="field no-highlighting">
|
||||
<LogMessageAnsi value={log.body} highlight={highlight} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!syntaxHighlighting) {
|
||||
return <span className="field no-highlighting">{log.body}</span>;
|
||||
}
|
||||
|
||||
return <span className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />;
|
||||
};
|
||||
|
||||
@@ -142,11 +154,11 @@ export type LogLineStyles = ReturnType<typeof getStyles>;
|
||||
export const getStyles = (theme: GrafanaTheme2) => {
|
||||
const colors = {
|
||||
critical: '#B877D9',
|
||||
error: '#f22f44',
|
||||
error: theme.colors.error.text,
|
||||
warning: '#FBAD37',
|
||||
debug: '#6CCF8E',
|
||||
debug: '#6E9FFF',
|
||||
trace: '#6ed0e0',
|
||||
info: '#6E9FFF',
|
||||
info: '#6CCF8E',
|
||||
metadata: theme.colors.text.primary,
|
||||
parsedField: theme.colors.text.primary,
|
||||
};
|
||||
@@ -161,7 +173,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
fontSize: theme.typography.fontSize,
|
||||
wordBreak: 'break-all',
|
||||
'&:hover': {
|
||||
background: `hsla(0, 0%, 0%, 0.1)`,
|
||||
background: `hsla(0, 0%, 0%, 0.2)`,
|
||||
},
|
||||
'&.infinite-scroll': {
|
||||
'&::before': {
|
||||
@@ -205,6 +217,9 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
color: theme.colors.info.shade,
|
||||
},
|
||||
},
|
||||
'& .no-highlighting': {
|
||||
color: theme.colors.text.primary,
|
||||
},
|
||||
}),
|
||||
pinnedLogLine: css({
|
||||
backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(),
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { createTheme } from '@grafana/data';
|
||||
import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
||||
|
||||
import { createLogLine } from '../__mocks__/logRow';
|
||||
|
||||
import { getStyles } from './LogLine';
|
||||
import { LogLineMenu } from './LogLineMenu';
|
||||
import { LogListContext } from './LogListContext';
|
||||
import { LogListContextProvider } from './LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
jest.mock('./LogListContext');
|
||||
|
||||
const theme = createTheme();
|
||||
const styles = getStyles(theme);
|
||||
const contextProps = {
|
||||
app: CoreApp.Unknown,
|
||||
dedupStrategy: LogsDedupStrategy.exact,
|
||||
displayedFields: [],
|
||||
showControls: false,
|
||||
showTime: false,
|
||||
sortOrder: LogsSortOrder.Ascending,
|
||||
wrapLogMessage: false,
|
||||
};
|
||||
|
||||
describe('LogLineMenu', () => {
|
||||
let log: LogListModel;
|
||||
@@ -30,9 +41,9 @@ describe('LogLineMenu', () => {
|
||||
test('Allows to copy a permalink', async () => {
|
||||
const onPermalinkClick = jest.fn();
|
||||
render(
|
||||
<LogListContext.Provider value={{ onPermalinkClick }}>
|
||||
<LogListContextProvider {...contextProps} onPermalinkClick={onPermalinkClick}>
|
||||
<LogLineMenu log={log} styles={styles} />
|
||||
</LogListContext.Provider>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Log menu'));
|
||||
await userEvent.click(screen.getByText('Copy link to log line'));
|
||||
@@ -44,9 +55,14 @@ describe('LogLineMenu', () => {
|
||||
const logSupportsContext = jest.fn().mockReturnValue(true);
|
||||
const getRowContextQuery = jest.fn();
|
||||
render(
|
||||
<LogListContext.Provider value={{ getRowContextQuery, logSupportsContext, onOpenContext }}>
|
||||
<LogListContextProvider
|
||||
{...contextProps}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
logSupportsContext={logSupportsContext}
|
||||
onOpenContext={onOpenContext}
|
||||
>
|
||||
<LogLineMenu log={log} styles={styles} />
|
||||
</LogListContext.Provider>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Log menu'));
|
||||
await userEvent.click(screen.getByText('Show context'));
|
||||
@@ -58,9 +74,14 @@ describe('LogLineMenu', () => {
|
||||
const logSupportsContext = jest.fn().mockReturnValue(false);
|
||||
const getRowContextQuery = jest.fn();
|
||||
render(
|
||||
<LogListContext.Provider value={{ getRowContextQuery, logSupportsContext, onOpenContext }}>
|
||||
<LogListContextProvider
|
||||
{...contextProps}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
logSupportsContext={logSupportsContext}
|
||||
onOpenContext={onOpenContext}
|
||||
>
|
||||
<LogLineMenu log={log} styles={styles} />
|
||||
</LogListContext.Provider>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Log menu'));
|
||||
expect(screen.queryByText('Show context')).not.toBeInTheDocument();
|
||||
@@ -69,9 +90,9 @@ describe('LogLineMenu', () => {
|
||||
test('Allows to pin log line', async () => {
|
||||
const onPinLine = jest.fn();
|
||||
render(
|
||||
<LogListContext.Provider value={{ pinnedLogs: [], onPinLine }}>
|
||||
<LogListContextProvider {...contextProps} pinnedLogs={[]} onPinLine={onPinLine}>
|
||||
<LogLineMenu log={log} styles={styles} />
|
||||
</LogListContext.Provider>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Log menu'));
|
||||
await userEvent.click(screen.getByText('Pin log'));
|
||||
@@ -81,9 +102,9 @@ describe('LogLineMenu', () => {
|
||||
test('Allows to unpin log line', async () => {
|
||||
const onUnpinLine = jest.fn();
|
||||
render(
|
||||
<LogListContext.Provider value={{ pinnedLogs: [log.uid], onUnpinLine }}>
|
||||
<LogListContextProvider {...contextProps} pinnedLogs={[log.uid]} onUnpinLine={onUnpinLine}>
|
||||
<LogLineMenu log={log} styles={styles} />
|
||||
</LogListContext.Provider>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Log menu'));
|
||||
expect(screen.queryByText('Pin log')).not.toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
||||
|
||||
import { createLogRow } from '../__mocks__/logRow';
|
||||
|
||||
import { LogList } from './LogList';
|
||||
|
||||
const logs: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' })];
|
||||
|
||||
describe('LogList', () => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
await userEvent.hover(screen.getByText('log message 1'));
|
||||
expect(onLogRowHover).toHaveBeenCalledTimes(1);
|
||||
expect(onLogRowHover).toHaveBeenCalledWith(expect.objectContaining(logs[0]));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { debounce } from 'lodash';
|
||||
import { Grammar } from 'prismjs';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { VariableSizeList } from 'react-window';
|
||||
|
||||
@@ -8,9 +9,12 @@ import {
|
||||
CoreApp,
|
||||
DataFrame,
|
||||
EventBus,
|
||||
EventBusSrv,
|
||||
Field,
|
||||
LinkModel,
|
||||
LogLevel,
|
||||
LogRowModel,
|
||||
LogsDedupStrategy,
|
||||
LogsSortOrder,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
@@ -19,7 +23,8 @@ import { PopoverContent, useTheme2 } from '@grafana/ui';
|
||||
import { InfiniteScroll } from './InfiniteScroll';
|
||||
import { getGridTemplateColumns } from './LogLine';
|
||||
import { GetRowContextQueryFn } from './LogLineMenu';
|
||||
import { LogListContext } from './LogListContext';
|
||||
import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext';
|
||||
import { LogListControls } from './LogListControls';
|
||||
import { preProcessLogs, LogListModel } from './processing';
|
||||
import {
|
||||
calculateFieldDimensions,
|
||||
@@ -36,45 +41,130 @@ export type GetFieldLinksFn = (field: Field, rowIndex: number, dataFrame: DataFr
|
||||
interface Props {
|
||||
app: CoreApp;
|
||||
containerElement: HTMLDivElement;
|
||||
dedupStrategy: LogsDedupStrategy;
|
||||
displayedFields: string[];
|
||||
eventBus: EventBus;
|
||||
eventBus?: EventBus;
|
||||
filterLevels?: LogLevel[];
|
||||
forceEscape?: boolean;
|
||||
getFieldLinks?: GetFieldLinksFn;
|
||||
getRowContextQuery?: GetRowContextQueryFn;
|
||||
grammar?: Grammar;
|
||||
initialScrollPosition?: 'top' | 'bottom';
|
||||
loadMore?: (range: AbsoluteTimeRange) => void;
|
||||
logOptionsStorageKey?: string;
|
||||
logs: LogRowModel[];
|
||||
logSupportsContext?: (row: LogRowModel) => boolean;
|
||||
onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
|
||||
onLogLineHover?: (row?: LogRowModel) => void;
|
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
|
||||
onPinLine?: (row: LogRowModel) => void;
|
||||
onOpenContext?: (row: LogRowModel, onClose: () => void) => void;
|
||||
onUnpinLine?: (row: LogRowModel) => void;
|
||||
pinLineButtonTooltipTitle?: PopoverContent;
|
||||
pinnedLogs?: string[];
|
||||
showControls: boolean;
|
||||
showTime: boolean;
|
||||
sortOrder: LogsSortOrder;
|
||||
storageKey?: string;
|
||||
timeRange: TimeRange;
|
||||
timeZone: string;
|
||||
syntaxHighlighting?: boolean;
|
||||
wrapLogMessage: boolean;
|
||||
}
|
||||
|
||||
export type LogListControlOptions = LogListState;
|
||||
|
||||
type LogListComponentProps = Omit<
|
||||
Props,
|
||||
'app' | 'dedupStrategy' | 'displayedFields' | 'showTime' | 'sortOrder' | 'syntaxHighlighting' | 'wrapLogMessage'
|
||||
>;
|
||||
|
||||
export const LogList = ({
|
||||
app,
|
||||
displayedFields,
|
||||
containerElement,
|
||||
displayedFields = [],
|
||||
dedupStrategy,
|
||||
eventBus,
|
||||
filterLevels,
|
||||
forceEscape = false,
|
||||
getFieldLinks,
|
||||
getRowContextQuery,
|
||||
grammar,
|
||||
initialScrollPosition = 'top',
|
||||
loadMore,
|
||||
logOptionsStorageKey,
|
||||
logs,
|
||||
logSupportsContext,
|
||||
onLogOptionsChange,
|
||||
onLogLineHover,
|
||||
onPermalinkClick,
|
||||
onPinLine,
|
||||
onOpenContext,
|
||||
onUnpinLine,
|
||||
pinLineButtonTooltipTitle,
|
||||
pinnedLogs,
|
||||
showControls,
|
||||
showTime,
|
||||
sortOrder,
|
||||
syntaxHighlighting,
|
||||
timeRange,
|
||||
timeZone,
|
||||
wrapLogMessage,
|
||||
...logListContext
|
||||
}: Props) => {
|
||||
return (
|
||||
<LogListContextProvider
|
||||
app={app}
|
||||
dedupStrategy={dedupStrategy}
|
||||
displayedFields={displayedFields}
|
||||
filterLevels={filterLevels}
|
||||
getRowContextQuery={getRowContextQuery}
|
||||
logOptionsStorageKey={logOptionsStorageKey}
|
||||
logSupportsContext={logSupportsContext}
|
||||
onLogOptionsChange={onLogOptionsChange}
|
||||
onLogLineHover={onLogLineHover}
|
||||
onPermalinkClick={onPermalinkClick}
|
||||
onPinLine={onPinLine}
|
||||
onOpenContext={onOpenContext}
|
||||
onUnpinLine={onUnpinLine}
|
||||
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
|
||||
pinnedLogs={pinnedLogs}
|
||||
showControls={showControls}
|
||||
showTime={showTime}
|
||||
sortOrder={sortOrder}
|
||||
syntaxHighlighting={syntaxHighlighting}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
>
|
||||
<LogListComponent
|
||||
containerElement={containerElement}
|
||||
eventBus={eventBus}
|
||||
forceEscape={forceEscape}
|
||||
getFieldLinks={getFieldLinks}
|
||||
grammar={grammar}
|
||||
initialScrollPosition={initialScrollPosition}
|
||||
loadMore={loadMore}
|
||||
logs={logs}
|
||||
showControls={showControls}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</LogListContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const LogListComponent = ({
|
||||
containerElement,
|
||||
eventBus = new EventBusSrv(),
|
||||
forceEscape = false,
|
||||
getFieldLinks,
|
||||
grammar,
|
||||
initialScrollPosition = 'top',
|
||||
loadMore,
|
||||
logs,
|
||||
showControls,
|
||||
timeRange,
|
||||
timeZone,
|
||||
}: LogListComponentProps) => {
|
||||
const { app, displayedFields, filterLevels, showTime, sortOrder, wrapLogMessage } = useLogListContext();
|
||||
const [processedLogs, setProcessedLogs] = useState<LogListModel[]>([]);
|
||||
const [listHeight, setListHeight] = useState(
|
||||
app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight
|
||||
@@ -101,8 +191,8 @@ export const LogList = ({
|
||||
}, [eventBus, logs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setProcessedLogs(preProcessLogs(logs, { getFieldLinks, escape: forceEscape, order: sortOrder, timeZone }));
|
||||
}, [forceEscape, getFieldLinks, logs, sortOrder, timeZone]);
|
||||
setProcessedLogs(preProcessLogs(logs, { getFieldLinks, escape: forceEscape, order: sortOrder, timeZone }, grammar));
|
||||
}, [forceEscape, getFieldLinks, grammar, logs, sortOrder, timeZone]);
|
||||
|
||||
useEffect(() => {
|
||||
resetLogLineSizes();
|
||||
@@ -148,12 +238,18 @@ export const LogList = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
filterLevels.length === 0 ? processedLogs : processedLogs.filter((log) => filterLevels.includes(log.logLevel)),
|
||||
[filterLevels, processedLogs]
|
||||
);
|
||||
|
||||
return (
|
||||
<LogListContext.Provider value={logListContext}>
|
||||
<div className={styles.logListContainer}>
|
||||
<InfiniteScroll
|
||||
displayedFields={displayedFields}
|
||||
handleOverflow={handleOverflow}
|
||||
logs={processedLogs}
|
||||
logs={filteredLogs}
|
||||
loadMore={loadMore}
|
||||
scrollElement={scrollRef.current}
|
||||
showTime={showTime}
|
||||
@@ -168,8 +264,9 @@ export const LogList = ({
|
||||
className={styles.logList}
|
||||
height={listHeight}
|
||||
itemCount={itemCount}
|
||||
itemSize={getLogLineSize.bind(null, processedLogs, containerElement, displayedFields, {
|
||||
itemSize={getLogLineSize.bind(null, filteredLogs, containerElement, displayedFields, {
|
||||
wrap: wrapLogMessage,
|
||||
showControls,
|
||||
showTime,
|
||||
})}
|
||||
itemKey={getItemKey}
|
||||
@@ -184,7 +281,8 @@ export const LogList = ({
|
||||
</VariableSizeList>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</LogListContext.Provider>
|
||||
{showControls && <LogListControls eventBus={eventBus} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -197,6 +295,9 @@ function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: bo
|
||||
gridTemplateColumns: getGridTemplateColumns(columns),
|
||||
},
|
||||
}),
|
||||
logListContainer: css({
|
||||
display: 'flex',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import { ReactNode } from 'react';
|
||||
import { createLogLine } from '../__mocks__/logRow';
|
||||
|
||||
import { useLogListContextData, useLogListContext, useLogIsPinned, LogListContext } from './LogListContext';
|
||||
import { defaultProps } from './__mocks__/LogListContext';
|
||||
|
||||
const log = createLogLine({ rowId: 'yep' });
|
||||
const value = {
|
||||
...defaultProps,
|
||||
getRowContextQuery: jest.fn(),
|
||||
logSupportsContext: jest.fn(),
|
||||
onPermalinkClick: jest.fn(),
|
||||
|
||||
@@ -1,22 +1,51 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { LogRowModel } from '@grafana/data';
|
||||
import { CoreApp, LogLevel, LogRowModel, LogsDedupStrategy, LogsSortOrder, shallowCompare, store } from '@grafana/data';
|
||||
import { PopoverContent } from '@grafana/ui';
|
||||
|
||||
import { GetRowContextQueryFn } from './LogLineMenu';
|
||||
|
||||
export interface LogListContextData {
|
||||
getRowContextQuery?: GetRowContextQueryFn;
|
||||
logSupportsContext?: (row: LogRowModel) => boolean;
|
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
|
||||
onPinLine?: (row: LogRowModel) => void;
|
||||
onOpenContext?: (row: LogRowModel, onClose: () => void) => void;
|
||||
onUnpinLine?: (row: LogRowModel) => void;
|
||||
pinLineButtonTooltipTitle?: PopoverContent;
|
||||
pinnedLogs?: string[];
|
||||
export interface LogListContextData extends Omit<Props, 'showControls'> {
|
||||
filterLevels: LogLevel[];
|
||||
setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void;
|
||||
setDisplayedFields: (displayedFields: string[]) => void;
|
||||
setFilterLevels: (filterLevels: LogLevel[]) => void;
|
||||
setLogListState: Dispatch<SetStateAction<LogListState>>;
|
||||
setPinnedLogs: (pinnedlogs: string[]) => void;
|
||||
setSyntaxHighlighting: (syntaxHighlighting: boolean) => void;
|
||||
setShowTime: (showTime: boolean) => void;
|
||||
setSortOrder: (sortOrder: LogsSortOrder) => void;
|
||||
setWrapLogMessage: (showTime: boolean) => void;
|
||||
}
|
||||
|
||||
export const LogListContext = createContext<LogListContextData>({});
|
||||
export const LogListContext = createContext<LogListContextData>({
|
||||
app: CoreApp.Unknown,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
displayedFields: [],
|
||||
filterLevels: [],
|
||||
setDedupStrategy: () => {},
|
||||
setDisplayedFields: () => {},
|
||||
setFilterLevels: () => {},
|
||||
setLogListState: () => {},
|
||||
setPinnedLogs: () => {},
|
||||
setShowTime: () => {},
|
||||
setSortOrder: () => {},
|
||||
setSyntaxHighlighting: () => {},
|
||||
setWrapLogMessage: () => {},
|
||||
showTime: true,
|
||||
sortOrder: LogsSortOrder.Ascending,
|
||||
syntaxHighlighting: true,
|
||||
wrapLogMessage: false,
|
||||
});
|
||||
|
||||
export const useLogListContextData = (key: keyof LogListContextData) => {
|
||||
const data: LogListContextData = useContext(LogListContext);
|
||||
@@ -31,3 +60,230 @@ export const useLogIsPinned = (log: LogRowModel) => {
|
||||
const { pinnedLogs } = useContext(LogListContext);
|
||||
return pinnedLogs?.some((logId) => logId === log.rowId);
|
||||
};
|
||||
|
||||
export type LogListState = Pick<
|
||||
LogListContextData,
|
||||
| 'dedupStrategy'
|
||||
| 'displayedFields'
|
||||
| 'filterLevels'
|
||||
| 'pinnedLogs'
|
||||
| 'showTime'
|
||||
| 'sortOrder'
|
||||
| 'syntaxHighlighting'
|
||||
| 'wrapLogMessage'
|
||||
>;
|
||||
|
||||
export interface Props {
|
||||
app: CoreApp;
|
||||
children?: ReactNode;
|
||||
dedupStrategy: LogsDedupStrategy;
|
||||
displayedFields: string[];
|
||||
filterLevels?: LogLevel[];
|
||||
getRowContextQuery?: GetRowContextQueryFn;
|
||||
logOptionsStorageKey?: string;
|
||||
logSupportsContext?: (row: LogRowModel) => boolean;
|
||||
onLogOptionsChange?: (option: keyof LogListState, value: string | boolean | string[]) => void;
|
||||
onLogLineHover?: (row?: LogRowModel) => void;
|
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
|
||||
onPinLine?: (row: LogRowModel) => void;
|
||||
onOpenContext?: (row: LogRowModel, onClose: () => void) => void;
|
||||
onUnpinLine?: (row: LogRowModel) => void;
|
||||
pinLineButtonTooltipTitle?: PopoverContent;
|
||||
pinnedLogs?: string[];
|
||||
showControls: boolean;
|
||||
showTime: boolean;
|
||||
sortOrder: LogsSortOrder;
|
||||
syntaxHighlighting?: boolean;
|
||||
wrapLogMessage: boolean;
|
||||
}
|
||||
|
||||
export const LogListContextProvider = ({
|
||||
app,
|
||||
children,
|
||||
dedupStrategy,
|
||||
displayedFields,
|
||||
getRowContextQuery,
|
||||
logOptionsStorageKey,
|
||||
filterLevels,
|
||||
logSupportsContext,
|
||||
onLogOptionsChange,
|
||||
onLogLineHover,
|
||||
onPermalinkClick,
|
||||
onPinLine,
|
||||
onOpenContext,
|
||||
onUnpinLine,
|
||||
pinLineButtonTooltipTitle,
|
||||
pinnedLogs,
|
||||
showControls,
|
||||
showTime,
|
||||
sortOrder,
|
||||
syntaxHighlighting = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true) : true,
|
||||
wrapLogMessage,
|
||||
}: Props) => {
|
||||
const [logListState, setLogListState] = useState<LogListState>({
|
||||
dedupStrategy,
|
||||
displayedFields,
|
||||
filterLevels:
|
||||
filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []),
|
||||
pinnedLogs,
|
||||
showTime,
|
||||
sortOrder,
|
||||
syntaxHighlighting,
|
||||
wrapLogMessage,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Props are updated in the context only of the panel is being externally controlled.
|
||||
if (showControls && app !== CoreApp.PanelEditor) {
|
||||
return;
|
||||
}
|
||||
const newState = {
|
||||
...logListState,
|
||||
dedupStrategy,
|
||||
showTime,
|
||||
sortOrder,
|
||||
syntaxHighlighting,
|
||||
wrapLogMessage,
|
||||
};
|
||||
if (!shallowCompare(logListState.displayedFields, displayedFields)) {
|
||||
newState.displayedFields = displayedFields;
|
||||
}
|
||||
if (!shallowCompare(logListState.pinnedLogs ?? [], pinnedLogs ?? [])) {
|
||||
newState.pinnedLogs = pinnedLogs;
|
||||
}
|
||||
if (!shallowCompare(logListState, newState)) {
|
||||
setLogListState(newState);
|
||||
}
|
||||
}, [
|
||||
app,
|
||||
dedupStrategy,
|
||||
displayedFields,
|
||||
logListState,
|
||||
pinnedLogs,
|
||||
showControls,
|
||||
showTime,
|
||||
sortOrder,
|
||||
syntaxHighlighting,
|
||||
wrapLogMessage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterLevels === undefined) {
|
||||
return;
|
||||
}
|
||||
if (!shallowCompare(logListState.filterLevels, filterLevels)) {
|
||||
setLogListState({ ...logListState, filterLevels });
|
||||
}
|
||||
}, [filterLevels, logListState]);
|
||||
|
||||
const setDedupStrategy = useCallback(
|
||||
(dedupStrategy: LogsDedupStrategy) => {
|
||||
setLogListState({ ...logListState, dedupStrategy });
|
||||
onLogOptionsChange?.('dedupStrategy', dedupStrategy);
|
||||
},
|
||||
[logListState, onLogOptionsChange]
|
||||
);
|
||||
|
||||
const setDisplayedFields = useCallback(
|
||||
(displayedFields: string[]) => {
|
||||
setLogListState({ ...logListState, displayedFields });
|
||||
onLogOptionsChange?.('displayedFields', displayedFields);
|
||||
},
|
||||
[logListState, onLogOptionsChange]
|
||||
);
|
||||
|
||||
const setFilterLevels = useCallback(
|
||||
(filterLevels: LogLevel[]) => {
|
||||
setLogListState({ ...logListState, filterLevels });
|
||||
onLogOptionsChange?.('filterLevels', filterLevels);
|
||||
},
|
||||
[logListState, onLogOptionsChange]
|
||||
);
|
||||
|
||||
const setPinnedLogs = useCallback(
|
||||
(pinnedLogs: string[]) => {
|
||||
setLogListState({ ...logListState, pinnedLogs });
|
||||
onLogOptionsChange?.('pinnedLogs', pinnedLogs);
|
||||
},
|
||||
[logListState, onLogOptionsChange]
|
||||
);
|
||||
|
||||
const setShowTime = useCallback(
|
||||
(showTime: boolean) => {
|
||||
setLogListState({ ...logListState, showTime });
|
||||
onLogOptionsChange?.('showTime', showTime);
|
||||
if (logOptionsStorageKey) {
|
||||
store.set(`${logOptionsStorageKey}.showTime`, showTime);
|
||||
}
|
||||
},
|
||||
[logListState, logOptionsStorageKey, onLogOptionsChange]
|
||||
);
|
||||
|
||||
const setSyntaxHighlighting = useCallback(
|
||||
(syntaxHighlighting: boolean) => {
|
||||
setLogListState({ ...logListState, syntaxHighlighting });
|
||||
onLogOptionsChange?.('syntaxHighlighting', syntaxHighlighting);
|
||||
if (logOptionsStorageKey) {
|
||||
store.set(`${logOptionsStorageKey}.syntaxHighlighting`, syntaxHighlighting);
|
||||
}
|
||||
},
|
||||
[logListState, logOptionsStorageKey, onLogOptionsChange]
|
||||
);
|
||||
|
||||
const setSortOrder = useCallback(
|
||||
(sortOrder: LogsSortOrder) => {
|
||||
setLogListState({ ...logListState, sortOrder });
|
||||
onLogOptionsChange?.('sortOrder', sortOrder);
|
||||
if (logOptionsStorageKey) {
|
||||
store.set(`${logOptionsStorageKey}.sortOrder`, sortOrder);
|
||||
}
|
||||
},
|
||||
[logListState, logOptionsStorageKey, onLogOptionsChange]
|
||||
);
|
||||
|
||||
const setWrapLogMessage = useCallback(
|
||||
(wrapLogMessage: boolean) => {
|
||||
setLogListState({ ...logListState, wrapLogMessage });
|
||||
onLogOptionsChange?.('wrapLogMessage', wrapLogMessage);
|
||||
if (logOptionsStorageKey) {
|
||||
store.set(`${logOptionsStorageKey}.wrapLogMessage`, wrapLogMessage);
|
||||
}
|
||||
},
|
||||
[logListState, logOptionsStorageKey, onLogOptionsChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<LogListContext.Provider
|
||||
value={{
|
||||
app,
|
||||
dedupStrategy: logListState.dedupStrategy,
|
||||
displayedFields: logListState.displayedFields,
|
||||
filterLevels: logListState.filterLevels,
|
||||
getRowContextQuery,
|
||||
logSupportsContext,
|
||||
onLogLineHover,
|
||||
onPermalinkClick,
|
||||
onPinLine,
|
||||
onOpenContext,
|
||||
onUnpinLine,
|
||||
pinLineButtonTooltipTitle,
|
||||
pinnedLogs: logListState.pinnedLogs,
|
||||
setDedupStrategy,
|
||||
setDisplayedFields,
|
||||
setFilterLevels,
|
||||
setLogListState,
|
||||
setPinnedLogs,
|
||||
setShowTime,
|
||||
setSortOrder,
|
||||
setSyntaxHighlighting,
|
||||
setWrapLogMessage,
|
||||
showTime: logListState.showTime,
|
||||
sortOrder: logListState.sortOrder,
|
||||
syntaxHighlighting: logListState.syntaxHighlighting,
|
||||
wrapLogMessage: logListState.wrapLogMessage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LogListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CoreApp, EventBusSrv, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
||||
|
||||
import { LogListContextProvider } from './LogListContext';
|
||||
import { LogListControls } from './LogListControls';
|
||||
import { ScrollToLogsEvent } from './virtualization';
|
||||
|
||||
const contextProps = {
|
||||
app: CoreApp.Unknown,
|
||||
dedupStrategy: LogsDedupStrategy.exact,
|
||||
displayedFields: [],
|
||||
showControls: true,
|
||||
showTime: false,
|
||||
sortOrder: LogsSortOrder.Ascending,
|
||||
syntaxHighlighting: false,
|
||||
wrapLogMessage: false,
|
||||
};
|
||||
|
||||
describe('LogListControls', () => {
|
||||
test('Renders without errors', () => {
|
||||
render(
|
||||
<LogListContextProvider {...contextProps}>
|
||||
<LogListControls eventBus={new EventBusSrv()} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Oldest logs first')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Deduplication')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Display levels')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Show timestamps')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Wrap lines')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Enable highlighting')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([CoreApp.Dashboard, CoreApp.PanelEditor, CoreApp.PanelViewer])(
|
||||
'Renders a subset of options for dashboards',
|
||||
(app: CoreApp) => {
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} app={app}>
|
||||
<LogListControls eventBus={new EventBusSrv()} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Display levels')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Oldest logs first')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Deduplication')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Show timestamps')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Wrap lines')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Enable highlighting')).not.toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
test('Allows to scroll', async () => {
|
||||
const eventBus = new EventBusSrv();
|
||||
jest.spyOn(eventBus, 'publish');
|
||||
render(
|
||||
<LogListContextProvider {...contextProps}>
|
||||
<LogListControls eventBus={eventBus} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Scroll to bottom'));
|
||||
await userEvent.click(screen.getByLabelText('Scroll to top'));
|
||||
expect(eventBus.publish).toHaveBeenCalledTimes(2);
|
||||
expect(eventBus.publish).toHaveBeenCalledWith(
|
||||
new ScrollToLogsEvent({
|
||||
scrollTo: 'bottom',
|
||||
})
|
||||
);
|
||||
expect(eventBus.publish).toHaveBeenCalledWith(
|
||||
new ScrollToLogsEvent({
|
||||
scrollTo: 'top',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('Controls sort order', async () => {
|
||||
const onLogOptionsChange = jest.fn();
|
||||
render(
|
||||
<LogListContextProvider
|
||||
{...contextProps}
|
||||
sortOrder={LogsSortOrder.Ascending}
|
||||
onLogOptionsChange={onLogOptionsChange}
|
||||
>
|
||||
<LogListControls eventBus={new EventBusSrv()} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Oldest logs first'));
|
||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||
expect(onLogOptionsChange).toHaveBeenCalledWith('sortOrder', LogsSortOrder.Descending);
|
||||
});
|
||||
|
||||
test('Controls deduplication', async () => {
|
||||
const onLogOptionsChange = jest.fn();
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} onLogOptionsChange={onLogOptionsChange}>
|
||||
<LogListControls eventBus={new EventBusSrv()} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Deduplication'));
|
||||
await userEvent.click(screen.getByText('Numbers'));
|
||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||
expect(onLogOptionsChange).toHaveBeenCalledWith('dedupStrategy', LogsDedupStrategy.numbers);
|
||||
});
|
||||
|
||||
test('Sets level filters', async () => {
|
||||
const onLogOptionsChange = jest.fn();
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} onLogOptionsChange={onLogOptionsChange}>
|
||||
<LogListControls eventBus={new EventBusSrv()} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Display levels'));
|
||||
expect(await screen.findByText('All levels')).toBeVisible();
|
||||
expect(screen.getByText('Info')).toBeVisible();
|
||||
expect(screen.getByText('Debug')).toBeVisible();
|
||||
expect(screen.getByText('Trace')).toBeVisible();
|
||||
expect(screen.getByText('Warning')).toBeVisible();
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
expect(screen.getByText('Critical')).toBeVisible();
|
||||
await userEvent.click(screen.getByText('Error'));
|
||||
expect(onLogOptionsChange).toHaveBeenCalledWith('filterLevels', ['error']);
|
||||
});
|
||||
|
||||
test('Controls timestamp visibility', async () => {
|
||||
const onLogOptionsChange = jest.fn();
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} showTime={false} onLogOptionsChange={onLogOptionsChange}>
|
||||
<LogListControls eventBus={new EventBusSrv()} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Show timestamps'));
|
||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
|
||||
});
|
||||
|
||||
test('Controls line wrapping', async () => {
|
||||
const onLogOptionsChange = jest.fn();
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} wrapLogMessage={false} onLogOptionsChange={onLogOptionsChange}>
|
||||
<LogListControls eventBus={new EventBusSrv()} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Wrap lines'));
|
||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
|
||||
});
|
||||
|
||||
test('Controls syntax highlighting', async () => {
|
||||
const onLogOptionsChange = jest.fn();
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} syntaxHighlighting={false} onLogOptionsChange={onLogOptionsChange}>
|
||||
<LogListControls eventBus={new EventBusSrv()} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Enable highlighting'));
|
||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||
expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { capitalize } from 'lodash';
|
||||
import { MouseEvent, useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
CoreApp,
|
||||
EventBus,
|
||||
GrafanaTheme2,
|
||||
LogLevel,
|
||||
LogsDedupDescription,
|
||||
LogsDedupStrategy,
|
||||
LogsSortOrder,
|
||||
} from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { Dropdown, IconButton, Menu, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { useLogListContext } from './LogListContext';
|
||||
import { ScrollToLogsEvent } from './virtualization';
|
||||
|
||||
type Props = {
|
||||
eventBus: EventBus;
|
||||
};
|
||||
|
||||
const DEDUP_OPTIONS = [
|
||||
LogsDedupStrategy.none,
|
||||
LogsDedupStrategy.exact,
|
||||
LogsDedupStrategy.numbers,
|
||||
LogsDedupStrategy.signature,
|
||||
];
|
||||
|
||||
const FILTER_LEVELS: LogLevel[] = [
|
||||
LogLevel.info,
|
||||
LogLevel.debug,
|
||||
LogLevel.trace,
|
||||
LogLevel.warning,
|
||||
LogLevel.error,
|
||||
LogLevel.critical,
|
||||
];
|
||||
|
||||
export const LogListControls = ({ eventBus }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const {
|
||||
app,
|
||||
dedupStrategy,
|
||||
filterLevels,
|
||||
setDedupStrategy,
|
||||
setFilterLevels,
|
||||
setShowTime,
|
||||
setSortOrder,
|
||||
setSyntaxHighlighting,
|
||||
setWrapLogMessage,
|
||||
showTime,
|
||||
sortOrder,
|
||||
syntaxHighlighting,
|
||||
wrapLogMessage,
|
||||
} = useLogListContext();
|
||||
|
||||
const onScrollToTopClick = useCallback(() => {
|
||||
reportInteraction('logs_log_list_controls_scroll_top_clicked');
|
||||
eventBus.publish(
|
||||
new ScrollToLogsEvent({
|
||||
scrollTo: 'top',
|
||||
})
|
||||
);
|
||||
}, [eventBus]);
|
||||
|
||||
const onScrollToBottomClick = useCallback(() => {
|
||||
reportInteraction('logs_log_list_controls_scroll_bottom_clicked');
|
||||
eventBus.publish(
|
||||
new ScrollToLogsEvent({
|
||||
scrollTo: 'bottom',
|
||||
})
|
||||
);
|
||||
}, [eventBus]);
|
||||
|
||||
const onFilterLevelClick = useCallback(
|
||||
(level?: LogLevel) => {
|
||||
reportInteraction('logs_log_list_controls_level_clicked');
|
||||
if (level === undefined) {
|
||||
setFilterLevels([]);
|
||||
} else if (!filterLevels.includes(level)) {
|
||||
setFilterLevels([...filterLevels, level]);
|
||||
} else {
|
||||
setFilterLevels(filterLevels.filter((filterLevel) => filterLevel !== level));
|
||||
}
|
||||
},
|
||||
[filterLevels, setFilterLevels]
|
||||
);
|
||||
|
||||
const onShowTimestampsClick = useCallback(() => {
|
||||
reportInteraction('logs_log_list_controls_show_time_clicked', {
|
||||
show_time: showTime,
|
||||
});
|
||||
setShowTime(!showTime);
|
||||
}, [setShowTime, showTime]);
|
||||
|
||||
const onSortOrderClick = useCallback(() => {
|
||||
reportInteraction('logs_log_list_controls_sort_order_clicked', {
|
||||
order: sortOrder === LogsSortOrder.Ascending ? LogsSortOrder.Descending : LogsSortOrder.Ascending,
|
||||
});
|
||||
setSortOrder(sortOrder === LogsSortOrder.Ascending ? LogsSortOrder.Descending : LogsSortOrder.Ascending);
|
||||
}, [setSortOrder, sortOrder]);
|
||||
|
||||
const onSyntaxHightlightingClick = useCallback(() => {
|
||||
reportInteraction('logs_log_list_controls_syntax_clicked', {
|
||||
state: !syntaxHighlighting,
|
||||
});
|
||||
setSyntaxHighlighting(!syntaxHighlighting);
|
||||
}, [setSyntaxHighlighting, syntaxHighlighting]);
|
||||
|
||||
const onWrapLogMessageClick = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
reportInteraction('logs_log_list_controls_wrap_clicked', {
|
||||
state: !wrapLogMessage,
|
||||
});
|
||||
setWrapLogMessage(!wrapLogMessage);
|
||||
},
|
||||
[setWrapLogMessage, wrapLogMessage]
|
||||
);
|
||||
|
||||
const deduplicationMenu = useMemo(
|
||||
() => (
|
||||
<Menu>
|
||||
{DEDUP_OPTIONS.map((option) => (
|
||||
<Menu.Item
|
||||
key={option}
|
||||
className={dedupStrategy === option ? styles.menuItemActive : undefined}
|
||||
description={LogsDedupDescription[option]}
|
||||
label={capitalize(option)}
|
||||
onClick={() => setDedupStrategy(option)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
),
|
||||
[dedupStrategy, setDedupStrategy, styles.menuItemActive]
|
||||
);
|
||||
|
||||
const filterLevelsMenu = useMemo(
|
||||
() => (
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
key={'all'}
|
||||
className={filterLevels.length === 0 ? styles.menuItemActive : undefined}
|
||||
label={t('logs.logs-controls.display-level-all', 'All levels')}
|
||||
onClick={() => onFilterLevelClick()}
|
||||
/>
|
||||
{FILTER_LEVELS.map((level) => (
|
||||
<Menu.Item
|
||||
key={level}
|
||||
className={filterLevels.includes(level) ? styles.menuItemActive : undefined}
|
||||
label={capitalize(level)}
|
||||
onClick={() => onFilterLevelClick(level)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
),
|
||||
[filterLevels, onFilterLevelClick, styles.menuItemActive]
|
||||
);
|
||||
|
||||
const inDashboard = app === CoreApp.Dashboard || app === CoreApp.PanelEditor || app === CoreApp.PanelViewer;
|
||||
|
||||
return (
|
||||
<div className={styles.navContainer}>
|
||||
<IconButton
|
||||
name="arrow-down"
|
||||
className={styles.controlButton}
|
||||
variant="secondary"
|
||||
onClick={onScrollToBottomClick}
|
||||
tooltip={t('logs.logs-controls.scroll-bottom', 'Scroll to bottom')}
|
||||
size="lg"
|
||||
/>
|
||||
{!inDashboard ? (
|
||||
<>
|
||||
<IconButton
|
||||
name={sortOrder === LogsSortOrder.Descending ? 'sort-amount-up' : 'sort-amount-down'}
|
||||
className={styles.controlButton}
|
||||
onClick={onSortOrderClick}
|
||||
tooltip={
|
||||
sortOrder === LogsSortOrder.Descending
|
||||
? t('logs.logs-controls.newest-first', 'Newest logs first')
|
||||
: t('logs.logs-controls.oldest-first', 'Oldest logs first')
|
||||
}
|
||||
size="lg"
|
||||
/>
|
||||
<Dropdown overlay={deduplicationMenu} placement="auto-end">
|
||||
<IconButton
|
||||
name={'filter'}
|
||||
className={dedupStrategy !== LogsDedupStrategy.none ? styles.controlButtonActive : styles.controlButton}
|
||||
tooltip={t('logs.logs-controls.deduplication', 'Deduplication')}
|
||||
size="lg"
|
||||
/>
|
||||
</Dropdown>
|
||||
<Dropdown overlay={filterLevelsMenu} placement="auto-end">
|
||||
<IconButton
|
||||
name={'gf-logs'}
|
||||
className={filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton}
|
||||
tooltip={t('logs.logs-controls.display-level', 'Display levels')}
|
||||
size="lg"
|
||||
/>
|
||||
</Dropdown>
|
||||
<IconButton
|
||||
name="clock-nine"
|
||||
aria-pressed={showTime}
|
||||
className={showTime ? styles.controlButtonActive : styles.controlButton}
|
||||
onClick={onShowTimestampsClick}
|
||||
tooltip={
|
||||
showTime
|
||||
? t('logs.logs-controls.hide-timestamps', 'Hide timestamps')
|
||||
: t('logs.logs-controls.show-timestamps', 'Show timestamps')
|
||||
}
|
||||
size="lg"
|
||||
/>
|
||||
<IconButton
|
||||
name="wrap-text"
|
||||
className={wrapLogMessage ? styles.controlButtonActive : styles.controlButton}
|
||||
aria-pressed={wrapLogMessage}
|
||||
onClick={onWrapLogMessageClick}
|
||||
tooltip={
|
||||
wrapLogMessage
|
||||
? t('logs.logs-controls.unwrap-lines', 'Unwrap lines')
|
||||
: t('logs.logs-controls.wrap-lines', 'Wrap lines')
|
||||
}
|
||||
size="lg"
|
||||
/>
|
||||
<IconButton
|
||||
name="brackets-curly"
|
||||
className={syntaxHighlighting ? styles.controlButtonActive : styles.controlButton}
|
||||
aria-pressed={syntaxHighlighting}
|
||||
onClick={onSyntaxHightlightingClick}
|
||||
tooltip={
|
||||
syntaxHighlighting
|
||||
? t('logs.logs-controls.disable-highlighting', 'Disable highlighting')
|
||||
: t('logs.logs-controls.enable-highlighting', 'Enable highlighting')
|
||||
}
|
||||
size="lg"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Dropdown overlay={filterLevelsMenu} placement="auto-end">
|
||||
<IconButton
|
||||
name={'gf-logs'}
|
||||
className={filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton}
|
||||
tooltip={t('logs.logs-controls.display-level', 'Display levels')}
|
||||
size="lg"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
<IconButton
|
||||
name="arrow-up"
|
||||
data-testid="scrollToTop"
|
||||
className={styles.scrollToTopButton}
|
||||
variant="secondary"
|
||||
onClick={onScrollToTopClick}
|
||||
tooltip={t('logs.logs-controls.scroll-top', 'Scroll to top')}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
navContainer: css({
|
||||
maxHeight: '100%',
|
||||
display: 'flex',
|
||||
gap: theme.spacing(3),
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
width: theme.spacing(4),
|
||||
paddingTop: theme.spacing(0.75),
|
||||
paddingLeft: theme.spacing(1),
|
||||
borderLeft: `solid 1px ${theme.colors.border.medium}`,
|
||||
}),
|
||||
scrollToTopButton: css({
|
||||
margin: 0,
|
||||
marginTop: 'auto',
|
||||
}),
|
||||
controlButton: css({
|
||||
margin: 0,
|
||||
color: theme.colors.text.secondary,
|
||||
height: theme.spacing(2),
|
||||
}),
|
||||
controlButtonActive: css({
|
||||
margin: 0,
|
||||
color: theme.colors.text.secondary,
|
||||
height: theme.spacing(2),
|
||||
'&:after': {
|
||||
display: 'block',
|
||||
content: '" "',
|
||||
position: 'absolute',
|
||||
height: 2,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
bottom: theme.spacing(-1),
|
||||
backgroundImage: theme.colors.gradients.brandHorizontal,
|
||||
width: '95%',
|
||||
opacity: 1,
|
||||
},
|
||||
}),
|
||||
menuItemActive: css({
|
||||
'&:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: theme.spacing(0.5),
|
||||
height: `calc(100% - ${theme.spacing(1)})`,
|
||||
width: '2px',
|
||||
backgroundColor: theme.colors.warning.main,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import { CoreApp, LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
||||
|
||||
import { LogListContextData, Props } from '../LogListContext';
|
||||
|
||||
export const LogListContext = createContext<LogListContextData>({
|
||||
app: CoreApp.Unknown,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
displayedFields: [],
|
||||
filterLevels: [],
|
||||
setDedupStrategy: () => {},
|
||||
setDisplayedFields: () => {},
|
||||
setFilterLevels: () => {},
|
||||
setLogListState: () => {},
|
||||
setPinnedLogs: () => {},
|
||||
setShowTime: () => {},
|
||||
setSortOrder: () => {},
|
||||
setSyntaxHighlighting: () => {},
|
||||
setWrapLogMessage: () => {},
|
||||
showTime: true,
|
||||
sortOrder: LogsSortOrder.Ascending,
|
||||
syntaxHighlighting: true,
|
||||
wrapLogMessage: false,
|
||||
});
|
||||
|
||||
export const useLogListContextData = (key: keyof LogListContextData) => {
|
||||
const data: LogListContextData = useContext(LogListContext);
|
||||
return data[key];
|
||||
};
|
||||
|
||||
export const useLogListContext = (): LogListContextData => {
|
||||
return useContext(LogListContext);
|
||||
};
|
||||
|
||||
export const useLogIsPinned = (log: LogRowModel) => {
|
||||
const { pinnedLogs } = useContext(LogListContext);
|
||||
return pinnedLogs?.some((logId) => logId === log.rowId);
|
||||
};
|
||||
|
||||
export const defaultProps = {
|
||||
app: CoreApp.Explore,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
displayedFields: [],
|
||||
filterLevels: [],
|
||||
getRowContextQuery: jest.fn(),
|
||||
logSupportsContext: jest.fn(),
|
||||
onPermalinkClick: jest.fn(),
|
||||
onPinLine: jest.fn(),
|
||||
onOpenContext: jest.fn(),
|
||||
onUnpinLine: jest.fn(),
|
||||
pinnedLogs: [],
|
||||
setDedupStrategy: jest.fn(),
|
||||
setDisplayedFields: jest.fn(),
|
||||
setFilterLevels: jest.fn(),
|
||||
setLogListState: jest.fn(),
|
||||
setPinnedLogs: jest.fn(),
|
||||
setShowTime: jest.fn(),
|
||||
setSortOrder: jest.fn(),
|
||||
setSyntaxHighlighting: jest.fn(),
|
||||
setWrapLogMessage: jest.fn(),
|
||||
showControls: true,
|
||||
showTime: true,
|
||||
sortOrder: LogsSortOrder.Descending,
|
||||
syntaxHighlighting: true,
|
||||
wrapLogMessage: true,
|
||||
};
|
||||
|
||||
export const LogListContextProvider = ({
|
||||
app = CoreApp.Explore,
|
||||
children,
|
||||
dedupStrategy = LogsDedupStrategy.none,
|
||||
displayedFields = [],
|
||||
filterLevels = [],
|
||||
getRowContextQuery = jest.fn(),
|
||||
logSupportsContext = jest.fn(),
|
||||
onPermalinkClick = jest.fn(),
|
||||
onPinLine = jest.fn(),
|
||||
onOpenContext = jest.fn(),
|
||||
onUnpinLine = jest.fn(),
|
||||
pinnedLogs = [],
|
||||
showTime = true,
|
||||
sortOrder = LogsSortOrder.Descending,
|
||||
syntaxHighlighting = true,
|
||||
wrapLogMessage = true,
|
||||
}: Partial<Props>) => {
|
||||
return (
|
||||
<LogListContext.Provider
|
||||
value={{
|
||||
app,
|
||||
dedupStrategy,
|
||||
displayedFields,
|
||||
filterLevels,
|
||||
getRowContextQuery,
|
||||
logSupportsContext,
|
||||
onPermalinkClick,
|
||||
onPinLine,
|
||||
onOpenContext,
|
||||
onUnpinLine,
|
||||
pinnedLogs,
|
||||
setDedupStrategy: jest.fn(),
|
||||
setDisplayedFields: jest.fn(),
|
||||
setFilterLevels: jest.fn(),
|
||||
setLogListState: jest.fn(),
|
||||
setPinnedLogs: jest.fn(),
|
||||
setShowTime: jest.fn(),
|
||||
setSortOrder: jest.fn(),
|
||||
setSyntaxHighlighting: jest.fn(),
|
||||
setWrapLogMessage: jest.fn(),
|
||||
showTime,
|
||||
sortOrder,
|
||||
syntaxHighlighting,
|
||||
wrapLogMessage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LogListContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import Prism from 'prismjs';
|
||||
import Prism, { Grammar } from 'prismjs';
|
||||
|
||||
import { dateTimeFormat, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data';
|
||||
|
||||
@@ -26,10 +26,11 @@ export interface PreProcessOptions {
|
||||
|
||||
export const preProcessLogs = (
|
||||
logs: LogRowModel[],
|
||||
{ escape, getFieldLinks, order, timeZone }: PreProcessOptions
|
||||
{ escape, getFieldLinks, order, timeZone }: PreProcessOptions,
|
||||
grammar?: Grammar
|
||||
): LogListModel[] => {
|
||||
const orderedLogs = sortLogRows(logs, order);
|
||||
return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, timeZone }));
|
||||
return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, timeZone }, grammar));
|
||||
};
|
||||
|
||||
interface PreProcessLogOptions {
|
||||
@@ -37,7 +38,11 @@ interface PreProcessLogOptions {
|
||||
getFieldLinks?: GetFieldLinksFn;
|
||||
timeZone: string;
|
||||
}
|
||||
const preProcessLog = (log: LogRowModel, { escape, getFieldLinks, timeZone }: PreProcessLogOptions): LogListModel => {
|
||||
const preProcessLog = (
|
||||
log: LogRowModel,
|
||||
{ escape, getFieldLinks, timeZone }: PreProcessLogOptions,
|
||||
grammar?: Grammar
|
||||
): LogListModel => {
|
||||
let body = log.raw;
|
||||
const timestamp = dateTimeFormat(log.timeEpochMs, {
|
||||
timeZone,
|
||||
@@ -56,7 +61,7 @@ const preProcessLog = (log: LogRowModel, { escape, getFieldLinks, timeZone }: Pr
|
||||
_highlightedBody: '',
|
||||
get highlightedBody() {
|
||||
if (!this._highlightedBody) {
|
||||
this._highlightedBody = Prism.highlight(body, generateLogGrammar(this), 'lokiql');
|
||||
this._highlightedBody = Prism.highlight(body, grammar ? grammar : generateLogGrammar(this), 'lokiql');
|
||||
}
|
||||
return this._highlightedBody;
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
|
||||
@@ -149,6 +150,7 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth =
|
||||
|
||||
interface DisplayOptions {
|
||||
wrap: boolean;
|
||||
showControls: boolean;
|
||||
showTime: boolean;
|
||||
}
|
||||
|
||||
@@ -156,7 +158,7 @@ export function getLogLineSize(
|
||||
logs: LogListModel[],
|
||||
container: HTMLDivElement | null,
|
||||
displayedFields: string[],
|
||||
{ wrap, showTime }: DisplayOptions,
|
||||
{ wrap, showControls, showTime }: DisplayOptions,
|
||||
index: number
|
||||
) {
|
||||
if (!container) {
|
||||
@@ -174,6 +176,9 @@ export function getLogLineSize(
|
||||
let textToMeasure = '';
|
||||
const gap = gridSize * FIELD_GAP_MULTIPLIER;
|
||||
let optionsWidth = 0;
|
||||
if (showControls) {
|
||||
optionsWidth += LOG_LIST_NAVIGATION_WIDTH;
|
||||
}
|
||||
if (showTime) {
|
||||
optionsWidth += gap;
|
||||
textToMeasure += logs[index].timestamp;
|
||||
|
||||
@@ -102,7 +102,7 @@ export function dedupLogRows(rows: LogRowModel[], strategy?: LogsDedupStrategy):
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function filterLogLevels(logRows: LogRowModel[], hiddenLogLevels: Set<LogLevel>): LogRowModel[] {
|
||||
export function filterLogLevels(logRows: LogRowModel[], hiddenLogLevels: Set<string>): LogRowModel[] {
|
||||
if (hiddenLogLevels.size === 0) {
|
||||
return logRows;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import {
|
||||
LoadingState,
|
||||
createDataFrame,
|
||||
FieldType,
|
||||
LogsSortOrder,
|
||||
getDefaultTimeRange,
|
||||
LogsDedupStrategy,
|
||||
EventBusSrv,
|
||||
DataFrameType,
|
||||
LogSortOrderChangeEvent,
|
||||
} from '@grafana/data';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
|
||||
import { LogsPanel } from './LogsPanel';
|
||||
|
||||
type LogsPanelProps = ComponentProps<typeof LogsPanel>;
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getAppEvents: jest.fn(),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
data: {
|
||||
error: undefined,
|
||||
request: {
|
||||
panelId: 4,
|
||||
app: 'dashboard',
|
||||
requestId: 'A',
|
||||
timezone: 'browser',
|
||||
interval: '30s',
|
||||
intervalMs: 30000,
|
||||
maxDataPoints: 823,
|
||||
targets: [],
|
||||
range: getDefaultTimeRange(),
|
||||
scopedVars: {},
|
||||
startTime: 1,
|
||||
},
|
||||
series: [
|
||||
createDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{
|
||||
name: 'timestamp',
|
||||
type: FieldType.time,
|
||||
values: ['2019-04-26T09:28:11.352440161Z'],
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: FieldType.string,
|
||||
values: ['logline text'],
|
||||
},
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
values: [
|
||||
{
|
||||
app: 'common_app',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
type: DataFrameType.LogLines,
|
||||
},
|
||||
}),
|
||||
],
|
||||
state: LoadingState.Done,
|
||||
timeRange: getDefaultTimeRange(),
|
||||
},
|
||||
timeZone: 'utc',
|
||||
timeRange: getDefaultTimeRange(),
|
||||
options: {
|
||||
showLabels: false,
|
||||
showTime: false,
|
||||
wrapLogMessage: false,
|
||||
sortOrder: LogsSortOrder.Descending,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
enableLogDetails: false,
|
||||
enableInfiniteScrolling: false,
|
||||
showControls: false,
|
||||
syntaxHighlighting: false,
|
||||
},
|
||||
title: 'Logs panel',
|
||||
id: 1,
|
||||
transparent: false,
|
||||
width: 400,
|
||||
height: 100,
|
||||
renderCounter: 0,
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
eventBus: new EventBusSrv(),
|
||||
onOptionsChange: jest.fn(),
|
||||
onFieldConfigChange: jest.fn(),
|
||||
replaceVariables: jest.fn(),
|
||||
onChangeTimeRange: jest.fn(),
|
||||
};
|
||||
|
||||
const publishMock = jest.fn();
|
||||
beforeAll(() => {
|
||||
jest.mocked(getAppEvents).mockReturnValue({
|
||||
publish: publishMock,
|
||||
getStream: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
removeAllListeners: jest.fn(),
|
||||
newScopedBus: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
describe('LogsPanel', () => {
|
||||
test('Renders a list of logs without controls ', async () => {
|
||||
setup();
|
||||
expect(await screen.findByText('logline text')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Scroll to bottom')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Display levels')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Scroll to top')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Renders a list of logs with controls', async () => {
|
||||
setup({ options: { ...defaultProps.options, showControls: true } });
|
||||
expect(await screen.findByText('logline text')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Display levels')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Publishes an event with the current sort order', async () => {
|
||||
publishMock.mockClear();
|
||||
setup();
|
||||
|
||||
await screen.findByText('logline text');
|
||||
|
||||
expect(publishMock).toHaveBeenCalledTimes(1);
|
||||
expect(publishMock).toHaveBeenCalledWith(
|
||||
new LogSortOrderChangeEvent({
|
||||
order: LogsSortOrder.Descending,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const setup = (propsOverrides?: Partial<LogsPanelProps>) => {
|
||||
const props: LogsPanelProps = {
|
||||
...defaultProps,
|
||||
data: {
|
||||
...(propsOverrides?.data || defaultProps.data),
|
||||
},
|
||||
options: {
|
||||
...(propsOverrides?.options || defaultProps.options),
|
||||
},
|
||||
};
|
||||
|
||||
return { ...render(<LogsPanel {...props} />), props };
|
||||
};
|
||||
@@ -5,22 +5,25 @@ import {
|
||||
AbsoluteTimeRange,
|
||||
CoreApp,
|
||||
DataFrame,
|
||||
DataHoverEvent,
|
||||
GrafanaTheme2,
|
||||
LoadingState,
|
||||
LogRowModel,
|
||||
LogSortOrderChangeEvent,
|
||||
LogsSortOrder,
|
||||
PanelProps,
|
||||
} from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { config, getAppEvents } from '@grafana/runtime';
|
||||
import { usePanelContext, useStyles2 } from '@grafana/ui';
|
||||
import { LogList } from 'app/features/logs/components/panel/LogList';
|
||||
import { PanelDataErrorView } from 'app/features/panel/components/PanelDataErrorView';
|
||||
|
||||
import { dataFrameToLogsModel, dedupLogRows } from '../../../features/logs/logsModel';
|
||||
import { requestMoreLogs } from '../logs/LogsPanel';
|
||||
import { isOnNewLogsReceivedType } from '../logs/types';
|
||||
import { useDatasourcesFromTargets } from '../logs/useDatasourcesFromTargets';
|
||||
|
||||
import { Options } from './panelcfg.gen';
|
||||
import { isCoreApp, isLogsGrammar, isOnLogOptionsChange, isOnNewLogsReceivedType } from './types';
|
||||
|
||||
interface LogsPanelProps extends PanelProps<Options> {}
|
||||
|
||||
@@ -28,7 +31,18 @@ export const LogsPanel = ({
|
||||
data,
|
||||
timeZone,
|
||||
fieldConfig,
|
||||
options: { dedupStrategy, enableInfiniteScrolling, onNewLogsReceived, showTime, sortOrder, wrapLogMessage },
|
||||
options: {
|
||||
dedupStrategy,
|
||||
enableInfiniteScrolling,
|
||||
grammar,
|
||||
onLogOptionsChange,
|
||||
onNewLogsReceived,
|
||||
showControls,
|
||||
showTime,
|
||||
sortOrder,
|
||||
syntaxHighlighting,
|
||||
wrapLogMessage,
|
||||
},
|
||||
id,
|
||||
}: LogsPanelProps) => {
|
||||
const style = useStyles2(getStyles);
|
||||
@@ -39,7 +53,7 @@ export const LogsPanel = ({
|
||||
const keepScrollPositionRef = useRef(false);
|
||||
// Loading ref to prevent firing multiple requests
|
||||
const loadingRef = useRef(false);
|
||||
const { eventBus } = usePanelContext();
|
||||
const { app, eventBus } = usePanelContext();
|
||||
|
||||
const logs = useMemo(() => {
|
||||
const logsModel = panelData
|
||||
@@ -48,6 +62,14 @@ export const LogsPanel = ({
|
||||
return logsModel ? dedupLogRows(logsModel.rows, dedupStrategy) : [];
|
||||
}, [dedupStrategy, panelData]);
|
||||
|
||||
useEffect(() => {
|
||||
getAppEvents().publish(
|
||||
new LogSortOrderChangeEvent({
|
||||
order: sortOrder,
|
||||
})
|
||||
);
|
||||
}, [sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.state !== LoadingState.Loading) {
|
||||
setPanelData(data);
|
||||
@@ -81,6 +103,21 @@ export const LogsPanel = ({
|
||||
[data.request, dataSourcesMap, onNewLogsReceived, panelData, timeZone]
|
||||
);
|
||||
|
||||
const onLogRowHover = useCallback(
|
||||
(row?: LogRowModel) => {
|
||||
if (row) {
|
||||
eventBus.publish(
|
||||
new DataHoverEvent({
|
||||
point: {
|
||||
time: row.timeEpochMs,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[eventBus]
|
||||
);
|
||||
|
||||
const initialScrollPosition = useMemo(() => {
|
||||
/**
|
||||
* In dashboards, users with newest logs at the bottom have the expectation of keeping the scroll at the bottom
|
||||
@@ -100,15 +137,20 @@ export const LogsPanel = ({
|
||||
<div className={style.container} ref={(element: HTMLDivElement) => setLogsContainer(element)}>
|
||||
{logs.length > 0 && logsContainer && (
|
||||
<LogList
|
||||
app={CoreApp.Dashboard}
|
||||
app={isCoreApp(app) ? app : CoreApp.Dashboard}
|
||||
containerElement={logsContainer}
|
||||
dedupStrategy={dedupStrategy}
|
||||
displayedFields={[]}
|
||||
eventBus={eventBus}
|
||||
grammar={isLogsGrammar(grammar) ? grammar : undefined}
|
||||
initialScrollPosition={initialScrollPosition}
|
||||
logs={logs}
|
||||
loadMore={enableInfiniteScrolling ? loadMoreLogs : undefined}
|
||||
onLogOptionsChange={isOnLogOptionsChange(onLogOptionsChange) ? onLogOptionsChange : undefined}
|
||||
onLogLineHover={onLogRowHover}
|
||||
showControls={showControls}
|
||||
showTime={showTime}
|
||||
sortOrder={sortOrder}
|
||||
syntaxHighlighting={syntaxHighlighting}
|
||||
timeRange={data.timeRange}
|
||||
timeZone={timeZone}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
|
||||
@@ -19,12 +19,24 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
|
||||
description: '',
|
||||
defaultValue: false,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'syntaxHighlighting',
|
||||
name: 'Enable syntax highlighting',
|
||||
description: 'Use a predefined syntax coloring grammar to highlight relevant parts of the log lines',
|
||||
defaultValue: true,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'enableLogDetails',
|
||||
name: 'Enable log details',
|
||||
description: '',
|
||||
defaultValue: true,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'showControls',
|
||||
name: 'Show controls',
|
||||
description: 'Display controls to jump to the last or first log line, and filters by log level',
|
||||
defaultValue: false,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'enableInfiniteScrolling',
|
||||
name: 'Enable infinite scrolling',
|
||||
|
||||
@@ -26,12 +26,16 @@ composableKinds: PanelCfg: {
|
||||
version: [0, 0]
|
||||
schema: {
|
||||
Options: {
|
||||
showControls: bool
|
||||
showTime: bool
|
||||
wrapLogMessage: bool
|
||||
enableLogDetails: bool
|
||||
syntaxHighlighting: bool
|
||||
sortOrder: common.LogsSortOrder
|
||||
dedupStrategy: common.LogsDedupStrategy
|
||||
grammar?: _
|
||||
enableInfiniteScrolling?: bool
|
||||
onLogOptionsChange?: _
|
||||
onNewLogsReceived?: _
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
|
||||
@@ -14,8 +14,12 @@ export interface Options {
|
||||
dedupStrategy: common.LogsDedupStrategy;
|
||||
enableInfiniteScrolling?: boolean;
|
||||
enableLogDetails: boolean;
|
||||
grammar?: unknown;
|
||||
onLogOptionsChange?: unknown;
|
||||
onNewLogsReceived?: unknown;
|
||||
showControls: boolean;
|
||||
showTime: boolean;
|
||||
sortOrder: common.LogsSortOrder;
|
||||
syntaxHighlighting: boolean;
|
||||
wrapLogMessage: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Grammar } from 'prismjs';
|
||||
|
||||
import { CoreApp, DataFrame } from '@grafana/data';
|
||||
import { LogListControlOptions } from 'app/features/logs/components/panel/LogList';
|
||||
|
||||
type onNewLogsReceivedType = (allLogs: DataFrame[], newLogs: DataFrame[]) => void;
|
||||
type onLogOptionsChangeType = (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
|
||||
|
||||
export function isOnNewLogsReceivedType(callback: unknown): callback is onNewLogsReceivedType {
|
||||
return typeof callback === 'function';
|
||||
}
|
||||
|
||||
export function isOnLogOptionsChange(callback: unknown): callback is onLogOptionsChangeType {
|
||||
return typeof callback === 'function';
|
||||
}
|
||||
|
||||
export function isLogsGrammar(grammar: unknown): grammar is Grammar {
|
||||
return grammar !== null && typeof grammar === 'object' && Object.getPrototypeOf(grammar) === Object.prototype;
|
||||
}
|
||||
|
||||
export function isCoreApp(app: unknown): app is CoreApp {
|
||||
return (
|
||||
app === CoreApp.CloudAlerting ||
|
||||
app === CoreApp.Correlations ||
|
||||
app === CoreApp.Dashboard ||
|
||||
app === CoreApp.Explore ||
|
||||
app === CoreApp.PanelEditor ||
|
||||
app === CoreApp.PanelViewer ||
|
||||
app === CoreApp.UnifiedAlerting ||
|
||||
app === CoreApp.Unknown
|
||||
);
|
||||
}
|
||||
@@ -4052,6 +4052,21 @@
|
||||
"shortcut": "alt+select to enable again"
|
||||
}
|
||||
},
|
||||
"logs-controls": {
|
||||
"deduplication": "Deduplication",
|
||||
"disable-highlighting": "Disable highlighting",
|
||||
"display-level": "Display levels",
|
||||
"display-level-all": "All levels",
|
||||
"enable-highlighting": "Enable highlighting",
|
||||
"hide-timestamps": "Hide timestamps",
|
||||
"newest-first": "Newest logs first",
|
||||
"oldest-first": "Oldest logs first",
|
||||
"scroll-bottom": "Scroll to bottom",
|
||||
"scroll-top": "Scroll to top",
|
||||
"show-timestamps": "Show timestamps",
|
||||
"unwrap-lines": "Unwrap lines",
|
||||
"wrap-lines": "Wrap lines"
|
||||
},
|
||||
"logs-navigation": {
|
||||
"newer-logs": "Newer logs",
|
||||
"older-logs": "Older logs",
|
||||
|
||||
Reference in New Issue
Block a user