mirror of
https://github.com/grafana/grafana.git
synced 2025-12-22 20:54:34 +08:00
Compare commits
5 Commits
docs/add-t
...
logs-custo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24a5c79f3 | ||
|
|
1756c45e33 | ||
|
|
9edf23ea68 | ||
|
|
3d15a07147 | ||
|
|
f0ef66eaa1 |
@@ -507,6 +507,7 @@ export type {
|
||||
ExploreCorrelationHelperData,
|
||||
ExploreTracePanelState,
|
||||
ExploreLogsPanelState,
|
||||
CustomHighlight,
|
||||
SplitOpenOptions,
|
||||
SplitOpen,
|
||||
TraceSearchProps,
|
||||
|
||||
@@ -76,6 +76,11 @@ export interface ExploreTracePanelState {
|
||||
spanFilters?: TraceSearchProps;
|
||||
}
|
||||
|
||||
export interface CustomHighlight {
|
||||
text: string;
|
||||
colorIndex: number;
|
||||
}
|
||||
|
||||
export interface ExploreLogsPanelState {
|
||||
id?: string;
|
||||
columns?: Record<number, string>;
|
||||
@@ -85,6 +90,7 @@ export interface ExploreLogsPanelState {
|
||||
refId?: string;
|
||||
displayedFields?: string[];
|
||||
sortOrder?: LogsSortOrder;
|
||||
customHighlights?: CustomHighlight[];
|
||||
}
|
||||
|
||||
export interface SplitOpenOptions<T extends AnyQuery = AnyQuery> {
|
||||
|
||||
@@ -428,6 +428,13 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
[sortOrderChanged]
|
||||
);
|
||||
|
||||
const onPanelStateChange = useCallback(
|
||||
(newState: ExploreLogsPanelState) => {
|
||||
dispatch(changePanelState(exploreId, 'logs', newState));
|
||||
},
|
||||
[dispatch, exploreId]
|
||||
);
|
||||
|
||||
const onChangeVisualisation = useCallback(
|
||||
(visualisation: LogsVisualisationType) => {
|
||||
setVisualisationType(visualisation);
|
||||
@@ -1141,6 +1148,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
onPermalinkClick={onPermalinkClick}
|
||||
onPinLine={onPinToContentOutlineClick}
|
||||
onUnpinLine={onPinToContentOutlineClick}
|
||||
panelState={panelState?.logs}
|
||||
onPanelStateChange={onPanelStateChange}
|
||||
permalinkedLogId={panelState?.logs?.id}
|
||||
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
|
||||
pinnedLogs={pinnedLogs}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface PopoverMenuProps {
|
||||
y: number;
|
||||
onClickFilterString?: (value: string, refId?: string) => void;
|
||||
onClickFilterOutString?: (value: string, refId?: string) => void;
|
||||
onClickHighlightText?: (text: string) => void;
|
||||
onDisable: () => void;
|
||||
row: LogRowModel;
|
||||
close: () => void;
|
||||
@@ -24,6 +25,7 @@ export const PopoverMenu = ({
|
||||
y,
|
||||
onClickFilterString,
|
||||
onClickFilterOutString,
|
||||
onClickHighlightText,
|
||||
selection,
|
||||
row,
|
||||
close,
|
||||
@@ -50,7 +52,7 @@ export const PopoverMenu = ({
|
||||
props.onDisable();
|
||||
}, [props, row.datasourceType, selection.length]);
|
||||
|
||||
const supported = onClickFilterString || onClickFilterOutString;
|
||||
const supported = onClickFilterString || onClickFilterOutString || onClickHighlightText;
|
||||
|
||||
if (!supported) {
|
||||
return null;
|
||||
@@ -88,6 +90,16 @@ export const PopoverMenu = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{onClickHighlightText && (
|
||||
<Menu.Item
|
||||
label={t('logs.popover-menu.highlight-occurrences', 'Highlight occurrences')}
|
||||
onClick={() => {
|
||||
onClickHighlightText(selection);
|
||||
close();
|
||||
track('highlight_occurrences', selection.length, row.datasourceType);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item label={t('logs.popover-menu.disable-menu', 'Disable menu')} onClick={onDisable} />
|
||||
</Menu>
|
||||
|
||||
@@ -35,7 +35,7 @@ function pruneObject(obj: object): object | undefined {
|
||||
}
|
||||
return value;
|
||||
});
|
||||
pruned = omitBy<typeof pruned>(pruned, isEmpty);
|
||||
pruned = omitBy<typeof pruned>(pruned, (value) => isEmpty(value) && typeof value !== 'number');
|
||||
if (isEmpty(pruned)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -539,7 +539,20 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
|
||||
'.log-search-match': {
|
||||
color: theme.components.textHighlight.text,
|
||||
backgroundColor: theme.components.textHighlight.background,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
padding: '0 1px',
|
||||
},
|
||||
// Generate highlight classes from entire theme palette
|
||||
...theme.visualization.palette
|
||||
.map((_, index) => ({
|
||||
[`.log-custom-highlight-${index}`]: {
|
||||
backgroundColor: theme.visualization.getColorByName(theme.visualization.palette[index]),
|
||||
color: theme.colors.getContrastText(
|
||||
theme.visualization.getColorByName(theme.visualization.palette[index])
|
||||
),
|
||||
},
|
||||
}))
|
||||
.reduce((acc, obj) => ({ ...acc, ...obj }), {}),
|
||||
},
|
||||
'& .no-highlighting': {
|
||||
color: theme.colors.text.primary,
|
||||
|
||||
@@ -6,9 +6,11 @@ import { Align, VariableSizeList } from 'react-window';
|
||||
|
||||
import {
|
||||
CoreApp,
|
||||
CustomHighlight,
|
||||
DataFrame,
|
||||
EventBus,
|
||||
EventBusSrv,
|
||||
ExploreLogsPanelState,
|
||||
GrafanaTheme2,
|
||||
LogLevel,
|
||||
LogRowModel,
|
||||
@@ -32,6 +34,7 @@ import { LogLineDetails, LogLineDetailsMode } from './LogLineDetails';
|
||||
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
|
||||
import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext';
|
||||
import { LogListControls } from './LogListControls';
|
||||
import { LogListHighlightContextProvider, useLogListHighlightContext } from './LogListHighlightContext';
|
||||
import { LOG_LIST_SEARCH_HEIGHT, LogListSearch } from './LogListSearch';
|
||||
import { LogListSearchContextProvider, useLogListSearchContext } from './LogListSearchContext';
|
||||
import { preProcessLogs, LogListModel, getLevelsFromLogs } from './processing';
|
||||
@@ -76,6 +79,8 @@ export interface Props {
|
||||
onPinLine?: (row: LogRowModel) => void;
|
||||
onOpenContext?: (row: LogRowModel, onClose: () => void) => void;
|
||||
onUnpinLine?: (row: LogRowModel) => void;
|
||||
panelState?: ExploreLogsPanelState;
|
||||
onPanelStateChange?: (panelState: ExploreLogsPanelState) => void;
|
||||
permalinkedLogId?: string;
|
||||
pinLineButtonTooltipTitle?: PopoverContent;
|
||||
pinnedLogs?: string[];
|
||||
@@ -153,6 +158,8 @@ export const LogList = ({
|
||||
onPinLine,
|
||||
onOpenContext,
|
||||
onUnpinLine,
|
||||
panelState,
|
||||
onPanelStateChange,
|
||||
permalinkedLogId,
|
||||
pinLineButtonTooltipTitle,
|
||||
pinnedLogs,
|
||||
@@ -170,6 +177,21 @@ export const LogList = ({
|
||||
timeZone,
|
||||
wrapLogMessage,
|
||||
}: Props) => {
|
||||
const customHighlights = (panelState?.customHighlights ?? []).map((h, index) => ({
|
||||
...h,
|
||||
colorIndex: h.colorIndex ?? index % 8,
|
||||
}));
|
||||
|
||||
const handleHighlightsChange = useCallback(
|
||||
(highlights: CustomHighlight[]) => {
|
||||
onPanelStateChange?.({
|
||||
...panelState,
|
||||
customHighlights: highlights.length > 0 ? highlights : undefined,
|
||||
});
|
||||
},
|
||||
[panelState, onPanelStateChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<LogListContextProvider
|
||||
app={app}
|
||||
@@ -221,22 +243,27 @@ export const LogList = ({
|
||||
showControls={showControls}
|
||||
>
|
||||
<LogListSearchContextProvider>
|
||||
<LogListComponent
|
||||
containerElement={containerElement}
|
||||
dataFrames={dataFrames}
|
||||
eventBus={eventBus}
|
||||
getFieldLinks={getFieldLinks}
|
||||
grammar={grammar}
|
||||
initialScrollPosition={initialScrollPosition}
|
||||
infiniteScrollMode={infiniteScrollMode}
|
||||
loading={loading}
|
||||
loadMore={loadMore}
|
||||
logs={logs}
|
||||
showControls={showControls}
|
||||
showFieldSelector={showFieldSelector}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
<LogListHighlightContextProvider
|
||||
customHighlights={customHighlights}
|
||||
onHighlightsChange={handleHighlightsChange}
|
||||
>
|
||||
<LogListComponent
|
||||
containerElement={containerElement}
|
||||
dataFrames={dataFrames}
|
||||
eventBus={eventBus}
|
||||
getFieldLinks={getFieldLinks}
|
||||
grammar={grammar}
|
||||
initialScrollPosition={initialScrollPosition}
|
||||
infiniteScrollMode={infiniteScrollMode}
|
||||
loading={loading}
|
||||
loadMore={loadMore}
|
||||
logs={logs}
|
||||
showControls={showControls}
|
||||
showFieldSelector={showFieldSelector}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</LogListHighlightContextProvider>
|
||||
</LogListSearchContextProvider>
|
||||
</LogDetailsContextProvider>
|
||||
</LogListContextProvider>
|
||||
@@ -279,6 +306,7 @@ const LogListComponent = ({
|
||||
wrapLogMessage,
|
||||
} = useLogListContext();
|
||||
const { detailsMode, showDetails, toggleDetails } = useLogDetailsContext();
|
||||
const { customHighlights, addHighlight } = useLogListHighlightContext();
|
||||
const [processedLogs, setProcessedLogs] = useState<LogListModel[]>([]);
|
||||
const [listHeight, setListHeight] = useState(getListHeight(containerElement, app));
|
||||
const theme = useTheme2();
|
||||
@@ -354,6 +382,7 @@ const LogListComponent = ({
|
||||
preProcessLogs(
|
||||
logs,
|
||||
{
|
||||
customHighlights,
|
||||
getFieldLinks,
|
||||
escape: forceEscape ?? false,
|
||||
prettifyJSON,
|
||||
@@ -367,7 +396,18 @@ const LogListComponent = ({
|
||||
);
|
||||
virtualization.resetLogLineSizes();
|
||||
listRef.current?.resetAfterIndex(0);
|
||||
}, [forceEscape, getFieldLinks, grammar, logs, prettifyJSON, sortOrder, timeZone, virtualization, wrapLogMessage]);
|
||||
}, [
|
||||
customHighlights,
|
||||
forceEscape,
|
||||
getFieldLinks,
|
||||
grammar,
|
||||
logs,
|
||||
prettifyJSON,
|
||||
sortOrder,
|
||||
timeZone,
|
||||
virtualization,
|
||||
wrapLogMessage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.resetAfterIndex(0);
|
||||
@@ -469,6 +509,7 @@ const LogListComponent = ({
|
||||
{...popoverState.popoverMenuCoordinates}
|
||||
onClickFilterString={onClickFilterString}
|
||||
onClickFilterOutString={onClickFilterOutString}
|
||||
onClickHighlightText={addHighlight}
|
||||
onDisable={onDisablePopoverMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { DownloadFormat } from '../../utils';
|
||||
|
||||
import { useLogListContext } from './LogListContext';
|
||||
import { LogListControlsOption, LogListControlsSelectOption } from './LogListControlsOption';
|
||||
import { useLogListHighlightContext } from './LogListHighlightContext';
|
||||
import { useLogListSearchContext } from './LogListSearchContext';
|
||||
import { LOG_LIST_CONTROLS_WIDTH, ScrollToLogsEvent } from './virtualization';
|
||||
|
||||
@@ -77,6 +78,7 @@ export const LogListControls = ({ eventBus, logLevels = FILTER_LEVELS, visualisa
|
||||
wrapLogMessage,
|
||||
} = useLogListContext();
|
||||
const { hideSearch, searchVisible, showSearch } = useLogListSearchContext();
|
||||
const { hasHighlights, resetHighlights } = useLogListHighlightContext();
|
||||
|
||||
const styles = useStyles2(getStyles, controlsExpanded);
|
||||
|
||||
@@ -355,6 +357,23 @@ export const LogListControls = ({ eventBus, logLevels = FILTER_LEVELS, visualisa
|
||||
size="lg"
|
||||
/>
|
||||
</Dropdown>
|
||||
{hasHighlights && (
|
||||
<>
|
||||
<div className={styles.divider} />
|
||||
<LogListControlsOption
|
||||
expanded={controlsExpanded}
|
||||
name="times-circle"
|
||||
className={styles.controlButtonActive}
|
||||
onClick={() => {
|
||||
resetHighlights();
|
||||
reportInteraction('logs_log_list_controls_reset_highlights_clicked');
|
||||
}}
|
||||
label={t('logs.logs-controls.reset-highlights', 'Reset highlights')}
|
||||
tooltip={t('logs.logs-controls.tooltip.reset-highlights', 'Clear all custom highlights')}
|
||||
size="lg"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.divider} />
|
||||
{config.featureToggles.newLogsPanel ? (
|
||||
<TimestampResolutionButton expanded={controlsExpanded} />
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { CustomHighlight } from '@grafana/data';
|
||||
|
||||
import { useLogListHighlightContext, LogListHighlightContext } from './LogListHighlightContext';
|
||||
|
||||
// Mock palette length to match typical theme palette size
|
||||
const MOCK_PALETTE_LENGTH = 50;
|
||||
|
||||
describe('LogListHighlightContext', () => {
|
||||
const createWrapper = (highlights: CustomHighlight[], onChange: (highlights: CustomHighlight[]) => void) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<LogListHighlightContext.Provider
|
||||
value={{
|
||||
customHighlights: highlights,
|
||||
addHighlight: (text: string) => {
|
||||
const filtered = highlights.filter((h) => h.text !== text);
|
||||
const nextColorIndex = filtered.length % MOCK_PALETTE_LENGTH;
|
||||
onChange([...filtered, { text, colorIndex: nextColorIndex }]);
|
||||
},
|
||||
resetHighlights: () => onChange([]),
|
||||
hasHighlights: highlights.length > 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LogListHighlightContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
test('provides default context values', () => {
|
||||
const value = {
|
||||
customHighlights: [],
|
||||
addHighlight: jest.fn(),
|
||||
resetHighlights: jest.fn(),
|
||||
hasHighlights: false,
|
||||
};
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<LogListHighlightContext.Provider value={value}>{children}</LogListHighlightContext.Provider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useLogListHighlightContext(), { wrapper });
|
||||
|
||||
expect(result.current).toEqual(value);
|
||||
});
|
||||
|
||||
test('addHighlight adds a new highlight', () => {
|
||||
let highlights: CustomHighlight[] = [];
|
||||
const onChange = (newHighlights: CustomHighlight[]) => {
|
||||
highlights = newHighlights;
|
||||
};
|
||||
const wrapper = createWrapper(highlights, onChange);
|
||||
|
||||
const { result } = renderHook(() => useLogListHighlightContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.addHighlight('test text');
|
||||
});
|
||||
|
||||
expect(highlights).toEqual([{ text: 'test text', colorIndex: 0 }]);
|
||||
});
|
||||
|
||||
test('addHighlight re-highlights existing text with next color', () => {
|
||||
let highlights: CustomHighlight[] = [
|
||||
{ text: 'first', colorIndex: 0 },
|
||||
{ text: 'second', colorIndex: 1 },
|
||||
];
|
||||
const onChange = (newHighlights: CustomHighlight[]) => {
|
||||
highlights = newHighlights;
|
||||
};
|
||||
|
||||
const { result, rerender } = renderHook(() => useLogListHighlightContext(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => createWrapper(highlights, onChange)({ children }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addHighlight('first');
|
||||
rerender();
|
||||
});
|
||||
|
||||
// First should be removed and re-added with color index 1 (since only 'second' remains before re-adding)
|
||||
expect(highlights).toEqual([
|
||||
{ text: 'second', colorIndex: 1 },
|
||||
{ text: 'first', colorIndex: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('resetHighlights clears all highlights', () => {
|
||||
let highlights: CustomHighlight[] = [
|
||||
{ text: 'first', colorIndex: 0 },
|
||||
{ text: 'second', colorIndex: 1 },
|
||||
];
|
||||
const onChange = (newHighlights: CustomHighlight[]) => {
|
||||
highlights = newHighlights;
|
||||
};
|
||||
const wrapper = createWrapper(highlights, onChange);
|
||||
|
||||
const { result } = renderHook(() => useLogListHighlightContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.resetHighlights();
|
||||
});
|
||||
|
||||
expect(highlights).toEqual([]);
|
||||
});
|
||||
|
||||
test('hasHighlights returns true when highlights exist', () => {
|
||||
const highlights: CustomHighlight[] = [{ text: 'test', colorIndex: 0 }];
|
||||
const onChange = jest.fn();
|
||||
const wrapper = createWrapper(highlights, onChange);
|
||||
|
||||
const { result } = renderHook(() => useLogListHighlightContext(), { wrapper });
|
||||
|
||||
expect(result.current.hasHighlights).toBe(true);
|
||||
});
|
||||
|
||||
test('hasHighlights returns false when no highlights', () => {
|
||||
const highlights: CustomHighlight[] = [];
|
||||
const onChange = jest.fn();
|
||||
const wrapper = createWrapper(highlights, onChange);
|
||||
|
||||
const { result } = renderHook(() => useLogListHighlightContext(), { wrapper });
|
||||
|
||||
expect(result.current.hasHighlights).toBe(false);
|
||||
});
|
||||
|
||||
test('color indexes cycle through available colors', () => {
|
||||
let highlights: CustomHighlight[] = [];
|
||||
const onChange = (newHighlights: CustomHighlight[]) => {
|
||||
highlights = newHighlights;
|
||||
};
|
||||
|
||||
const { result, rerender } = renderHook(() => useLogListHighlightContext(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => createWrapper(highlights, onChange)({ children }),
|
||||
});
|
||||
|
||||
// Add 51 highlights to test cycling (50 colors available)
|
||||
for (let i = 0; i < 51; i++) {
|
||||
act(() => {
|
||||
result.current.addHighlight(`text${i}`);
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
|
||||
expect(highlights).toHaveLength(51);
|
||||
// First highlight has color 0
|
||||
expect(highlights[0].colorIndex).toBe(0);
|
||||
// 50th highlight has color 49
|
||||
expect(highlights[49].colorIndex).toBe(49);
|
||||
// 51st highlight cycles back to color 0
|
||||
expect(highlights[50].colorIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createContext, ReactNode, useCallback, useContext } from 'react';
|
||||
|
||||
import { CustomHighlight } from '@grafana/data';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
export interface LogListHighlightContextData {
|
||||
customHighlights: CustomHighlight[];
|
||||
addHighlight: (text: string) => void;
|
||||
resetHighlights: () => void;
|
||||
hasHighlights: boolean;
|
||||
}
|
||||
|
||||
export const LogListHighlightContext = createContext<LogListHighlightContextData>({
|
||||
customHighlights: [],
|
||||
addHighlight: () => {},
|
||||
resetHighlights: () => {},
|
||||
hasHighlights: false,
|
||||
});
|
||||
|
||||
export const useLogListHighlightContext = (): LogListHighlightContextData => {
|
||||
return useContext(LogListHighlightContext);
|
||||
};
|
||||
|
||||
interface LogListHighlightContextProviderProps {
|
||||
children: ReactNode;
|
||||
customHighlights: CustomHighlight[];
|
||||
onHighlightsChange: (highlights: CustomHighlight[]) => void;
|
||||
}
|
||||
|
||||
export const LogListHighlightContextProvider = ({
|
||||
children,
|
||||
customHighlights,
|
||||
onHighlightsChange,
|
||||
}: LogListHighlightContextProviderProps) => {
|
||||
const theme = useTheme2();
|
||||
const paletteLength = theme.visualization.palette.length;
|
||||
|
||||
const addHighlight = useCallback(
|
||||
(text: string) => {
|
||||
// If text already exists, remove it first (to re-highlight with next color)
|
||||
const filtered = customHighlights.filter((h) => h.text !== text);
|
||||
// Auto-assign next color index (cycling through available colors)
|
||||
const nextColorIndex = filtered.length % paletteLength;
|
||||
onHighlightsChange([...filtered, { text, colorIndex: nextColorIndex }]);
|
||||
},
|
||||
[customHighlights, onHighlightsChange, paletteLength]
|
||||
);
|
||||
|
||||
const resetHighlights = useCallback(() => {
|
||||
onHighlightsChange([]);
|
||||
}, [onHighlightsChange]);
|
||||
|
||||
return (
|
||||
<LogListHighlightContext.Provider
|
||||
value={{
|
||||
customHighlights,
|
||||
addHighlight,
|
||||
resetHighlights,
|
||||
hasHighlights: customHighlights.length > 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LogListHighlightContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { CustomHighlight } from '@grafana/data';
|
||||
|
||||
import {
|
||||
LogListHighlightContext,
|
||||
type LogListHighlightContextData,
|
||||
useLogListHighlightContext,
|
||||
} from '../LogListHighlightContext';
|
||||
|
||||
// Re-export for tests that import from the mock
|
||||
export { LogListHighlightContext, useLogListHighlightContext };
|
||||
export type { LogListHighlightContextData };
|
||||
|
||||
export const defaultValue: LogListHighlightContextData = {
|
||||
customHighlights: [],
|
||||
addHighlight: jest.fn(),
|
||||
resetHighlights: jest.fn(),
|
||||
hasHighlights: false,
|
||||
};
|
||||
|
||||
interface LogListHighlightContextProviderProps {
|
||||
children: ReactNode;
|
||||
customHighlights?: CustomHighlight[];
|
||||
onHighlightsChange?: (highlights: CustomHighlight[]) => void;
|
||||
}
|
||||
|
||||
export const LogListHighlightContextProvider = ({
|
||||
children,
|
||||
customHighlights = [],
|
||||
onHighlightsChange,
|
||||
}: LogListHighlightContextProviderProps) => {
|
||||
return (
|
||||
<LogListHighlightContext.Provider
|
||||
value={{
|
||||
customHighlights,
|
||||
addHighlight: onHighlightsChange !== undefined ? jest.fn() : defaultValue.addHighlight,
|
||||
resetHighlights: onHighlightsChange !== undefined ? jest.fn() : defaultValue.resetHighlights,
|
||||
hasHighlights: customHighlights.length > 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LogListHighlightContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import Prism, { Token } from 'prismjs';
|
||||
|
||||
import { CustomHighlight } from '@grafana/data';
|
||||
|
||||
import { createLogLine } from '../mocks/logRow';
|
||||
|
||||
import { generateLogGrammar, generateTextMatchGrammar } from './grammar';
|
||||
import { generateCustomHighlightGrammar, generateLogGrammar, generateTextMatchGrammar } from './grammar';
|
||||
|
||||
describe('generateLogGrammar', () => {
|
||||
function generateScenario(entry: string) {
|
||||
@@ -125,3 +127,81 @@ describe('generateTextMatchGrammar', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCustomHighlightGrammar', () => {
|
||||
const originalErr = console.error;
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterAll(() => {
|
||||
console.error = originalErr;
|
||||
});
|
||||
|
||||
test('returns empty grammar for empty highlights array', () => {
|
||||
expect(generateCustomHighlightGrammar([])).toEqual({});
|
||||
});
|
||||
|
||||
test('creates token for single highlight', () => {
|
||||
const highlights: CustomHighlight[] = [{ text: 'error', colorIndex: 0 }];
|
||||
const grammar = generateCustomHighlightGrammar(highlights) as Record<string, RegExp | RegExp[]>;
|
||||
|
||||
expect(grammar).toHaveProperty('log-search-match log-custom-highlight-0');
|
||||
expect(grammar['log-search-match log-custom-highlight-0']).toEqual(/error/g);
|
||||
});
|
||||
|
||||
test('creates tokens for multiple highlights with different colors', () => {
|
||||
const highlights: CustomHighlight[] = [
|
||||
{ text: 'error', colorIndex: 0 },
|
||||
{ text: 'warning', colorIndex: 1 },
|
||||
{ text: 'info', colorIndex: 2 },
|
||||
];
|
||||
const grammar = generateCustomHighlightGrammar(highlights) as Record<string, RegExp | RegExp[]>;
|
||||
|
||||
expect(grammar).toHaveProperty('log-search-match log-custom-highlight-0');
|
||||
expect(grammar).toHaveProperty('log-search-match log-custom-highlight-1');
|
||||
expect(grammar).toHaveProperty('log-search-match log-custom-highlight-2');
|
||||
expect(grammar['log-search-match log-custom-highlight-0']).toEqual(/error/g);
|
||||
expect(grammar['log-search-match log-custom-highlight-1']).toEqual(/warning/g);
|
||||
expect(grammar['log-search-match log-custom-highlight-2']).toEqual(/info/g);
|
||||
});
|
||||
|
||||
test('escapes regex special characters in highlight text', () => {
|
||||
const highlights: CustomHighlight[] = [{ text: 'test.log[0]', colorIndex: 0 }];
|
||||
const grammar = generateCustomHighlightGrammar(highlights) as Record<string, RegExp | RegExp[]>;
|
||||
|
||||
expect(grammar).toHaveProperty('log-search-match log-custom-highlight-0');
|
||||
// Verify it escaped the special characters by trying to match the literal text
|
||||
const regex = grammar['log-search-match log-custom-highlight-0'] as RegExp;
|
||||
expect(regex.test('test.log[0]')).toBe(true);
|
||||
expect(regex.test('testXlogY0Z')).toBe(false); // Should not match if . and [] were treated as regex
|
||||
});
|
||||
|
||||
test('groups multiple highlights with same color index', () => {
|
||||
const highlights: CustomHighlight[] = [
|
||||
{ text: 'error', colorIndex: 0 },
|
||||
{ text: 'failure', colorIndex: 0 },
|
||||
];
|
||||
const grammar = generateCustomHighlightGrammar(highlights) as Record<string, RegExp | RegExp[]>;
|
||||
|
||||
expect(grammar).toHaveProperty('log-search-match log-custom-highlight-0');
|
||||
const tokenValue = grammar['log-search-match log-custom-highlight-0'];
|
||||
expect(Array.isArray(tokenValue)).toBe(true);
|
||||
expect(tokenValue).toHaveLength(2);
|
||||
expect(tokenValue).toEqual([/error/g, /failure/g]);
|
||||
});
|
||||
|
||||
test('handles invalid regex gracefully', () => {
|
||||
const highlights: CustomHighlight[] = [{ text: '(?invalid', colorIndex: 0 }];
|
||||
// Should not throw, but may log error to console (which we've mocked)
|
||||
expect(() => generateCustomHighlightGrammar(highlights)).not.toThrow();
|
||||
});
|
||||
|
||||
test('uses case-sensitive matching', () => {
|
||||
const highlights: CustomHighlight[] = [{ text: 'Error', colorIndex: 0 }];
|
||||
const grammar = generateCustomHighlightGrammar(highlights) as Record<string, RegExp | RegExp[]>;
|
||||
const regex = grammar['log-search-match log-custom-highlight-0'] as RegExp;
|
||||
|
||||
expect(regex.test('Error')).toBe(true);
|
||||
expect(regex.test('error')).toBe(false); // Case-sensitive
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Grammar } from 'prismjs';
|
||||
import { Grammar, GrammarValue } from 'prismjs';
|
||||
|
||||
import { escapeRegex, parseFlags } from '@grafana/data';
|
||||
import { CustomHighlight, escapeRegex, parseFlags } from '@grafana/data';
|
||||
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
@@ -86,3 +86,39 @@ export const generateTextMatchGrammar = (highlightWords: string[] | undefined =
|
||||
const cleanNeedle = (needle: string): string => {
|
||||
return needle.replace(/[[{(][\w,.\/:;<=>?:*+]+$/, '');
|
||||
};
|
||||
|
||||
export const generateCustomHighlightGrammar = (highlights: CustomHighlight[]): Grammar => {
|
||||
if (!highlights.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const grammar: Record<string, GrammarValue> = {};
|
||||
|
||||
// Create separate token types for each color index
|
||||
// This allows different CSS classes for different colors
|
||||
highlights.forEach((highlight) => {
|
||||
const tokenName = `log-search-match log-custom-highlight-${highlight.colorIndex}`;
|
||||
const escapedText = escapeRegex(highlight.text);
|
||||
|
||||
try {
|
||||
// Case-sensitive matching (using 'g' flag only, not 'gi')
|
||||
const regex = new RegExp(escapedText, 'g');
|
||||
|
||||
// If token already exists for this color, add to array
|
||||
if (grammar[tokenName]) {
|
||||
const existing = grammar[tokenName];
|
||||
if (Array.isArray(existing)) {
|
||||
existing.push(regex);
|
||||
} else {
|
||||
grammar[tokenName] = [existing, regex];
|
||||
}
|
||||
} else {
|
||||
grammar[tokenName] = regex;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`generateCustomHighlightGrammar: cannot generate regular expression from /${escapedText}/g`, e);
|
||||
}
|
||||
});
|
||||
|
||||
return grammar;
|
||||
};
|
||||
|
||||
@@ -278,6 +278,66 @@ Value"
|
||||
expect(logListModel.isJSON).toBe(false);
|
||||
});
|
||||
|
||||
test('setCustomHighlights invalidates highlight tokens', () => {
|
||||
const logListModel = createLogLine(
|
||||
{ labels: { place: 'luna' }, entry: 'error message error' },
|
||||
{
|
||||
escape: false,
|
||||
order: LogsSortOrder.Descending,
|
||||
timeZone: 'browser',
|
||||
wrapLogMessage: true,
|
||||
customHighlights: [{ text: 'error', colorIndex: 0 }],
|
||||
}
|
||||
);
|
||||
|
||||
// Access highlightedBodyTokens to populate the cache
|
||||
const initialTokens = logListModel.highlightedBodyTokens;
|
||||
expect(initialTokens).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: 'log-search-match log-custom-highlight-0' })])
|
||||
);
|
||||
|
||||
// Update custom highlights
|
||||
logListModel.setCustomHighlights([{ text: 'message', colorIndex: 1 }]);
|
||||
|
||||
// After setCustomHighlights, tokens should be regenerated with new highlights
|
||||
const updatedTokens = logListModel.highlightedBodyTokens;
|
||||
expect(updatedTokens).not.toEqual(initialTokens);
|
||||
expect(updatedTokens).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: 'log-search-match log-custom-highlight-1' })])
|
||||
);
|
||||
expect(updatedTokens).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: 'log-search-match log-custom-highlight-0' })])
|
||||
);
|
||||
});
|
||||
|
||||
test('setCustomHighlights with empty array removes custom highlights', () => {
|
||||
const logListModel = createLogLine(
|
||||
{ labels: { place: 'luna' }, entry: 'error message' },
|
||||
{
|
||||
escape: false,
|
||||
order: LogsSortOrder.Descending,
|
||||
timeZone: 'browser',
|
||||
wrapLogMessage: true,
|
||||
customHighlights: [{ text: 'error', colorIndex: 0 }],
|
||||
}
|
||||
);
|
||||
|
||||
// Access highlightedBodyTokens with custom highlight
|
||||
const initialTokens = logListModel.highlightedBodyTokens;
|
||||
expect(initialTokens).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: 'log-search-match log-custom-highlight-0' })])
|
||||
);
|
||||
|
||||
// Clear custom highlights
|
||||
logListModel.setCustomHighlights([]);
|
||||
|
||||
// After clearing, no custom highlight tokens should exist
|
||||
const updatedTokens = logListModel.highlightedBodyTokens;
|
||||
expect(updatedTokens).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: 'log-search-match log-custom-highlight-0' })])
|
||||
);
|
||||
});
|
||||
|
||||
describe('OTel logs', () => {
|
||||
const originalState = config.featureToggles.otelLogsFormatting;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LosslessNumber, parse, stringify } from 'lossless-json';
|
||||
import Prism, { Grammar, Token } from 'prismjs';
|
||||
|
||||
import {
|
||||
CustomHighlight,
|
||||
DataFrame,
|
||||
dateTimeFormat,
|
||||
Labels,
|
||||
@@ -20,7 +21,7 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { FieldDef, getAllFields } from '../logParser';
|
||||
import { identifyOTelLanguage, getOtelAttributesField, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
|
||||
|
||||
import { generateLogGrammar, generateTextMatchGrammar } from './grammar';
|
||||
import { generateCustomHighlightGrammar, generateLogGrammar, generateTextMatchGrammar } from './grammar';
|
||||
import { LogLineVirtualization } from './virtualization';
|
||||
|
||||
const TRUNCATION_DEFAULT_LENGTH = 50000;
|
||||
@@ -58,6 +59,7 @@ export class LogListModel implements LogRowModel {
|
||||
|
||||
private _body: string | undefined = undefined;
|
||||
private _currentSearch: string | undefined = undefined;
|
||||
private _customHighlights: CustomHighlight[] = [];
|
||||
private _grammar?: Grammar;
|
||||
private _highlightedBody: string | undefined = undefined;
|
||||
private _highlightedLogAttributesTokens: Array<string | Token> | undefined = undefined;
|
||||
@@ -72,7 +74,16 @@ export class LogListModel implements LogRowModel {
|
||||
|
||||
constructor(
|
||||
log: LogRowModel,
|
||||
{ escape, getFieldLinks, grammar, prettifyJSON, timeZone, virtualization, wrapLogMessage }: PreProcessLogOptions
|
||||
{
|
||||
customHighlights,
|
||||
escape,
|
||||
getFieldLinks,
|
||||
grammar,
|
||||
prettifyJSON,
|
||||
timeZone,
|
||||
virtualization,
|
||||
wrapLogMessage,
|
||||
}: PreProcessLogOptions
|
||||
) {
|
||||
// LogRowModel
|
||||
this.datasourceType = log.datasourceType;
|
||||
@@ -101,6 +112,7 @@ export class LogListModel implements LogRowModel {
|
||||
|
||||
// LogListModel
|
||||
this.displayLevel = logLevelToDisplayLevel(log.logLevel);
|
||||
this._customHighlights = customHighlights ?? [];
|
||||
this._getFieldLinks = getFieldLinks;
|
||||
this._grammar = grammar;
|
||||
this._prettifyJSON = Boolean(prettifyJSON);
|
||||
@@ -185,8 +197,10 @@ export class LogListModel implements LogRowModel {
|
||||
// Body is accessed first to trigger the getter code before generateLogGrammar()
|
||||
const body = this.body;
|
||||
this._grammar = this._grammar ?? generateLogGrammar(this);
|
||||
const customHighlightGrammar = generateCustomHighlightGrammar(this._customHighlights);
|
||||
const extraGrammar = generateTextMatchGrammar(this.searchWords, this._currentSearch);
|
||||
this._highlightTokens = Prism.tokenize(body, { ...extraGrammar, ...this._grammar });
|
||||
// Custom highlights first (higher priority), then search, then base grammar
|
||||
this._highlightTokens = Prism.tokenize(body, { ...customHighlightGrammar, ...extraGrammar, ...this._grammar });
|
||||
}
|
||||
return this._highlightTokens;
|
||||
}
|
||||
@@ -198,8 +212,13 @@ export class LogListModel implements LogRowModel {
|
||||
return [];
|
||||
}
|
||||
this._grammar = this._grammar ?? generateLogGrammar(this);
|
||||
const customHighlightGrammar = generateCustomHighlightGrammar(this._customHighlights);
|
||||
const extraGrammar = generateTextMatchGrammar(this.searchWords, this._currentSearch);
|
||||
this._highlightedLogAttributesTokens = Prism.tokenize(attributes, { ...extraGrammar, ...this._grammar });
|
||||
this._highlightedLogAttributesTokens = Prism.tokenize(attributes, {
|
||||
...customHighlightGrammar,
|
||||
...extraGrammar,
|
||||
...this._grammar,
|
||||
});
|
||||
}
|
||||
return this._highlightedLogAttributesTokens;
|
||||
}
|
||||
@@ -275,9 +294,16 @@ export class LogListModel implements LogRowModel {
|
||||
this._highlightTokens = undefined;
|
||||
this._highlightedLogAttributesTokens = undefined;
|
||||
}
|
||||
|
||||
setCustomHighlights(highlights: CustomHighlight[]) {
|
||||
this._customHighlights = highlights;
|
||||
this._highlightTokens = undefined;
|
||||
this._highlightedLogAttributesTokens = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreProcessOptions {
|
||||
customHighlights?: CustomHighlight[];
|
||||
escape: boolean;
|
||||
getFieldLinks?: GetFieldLinksFn;
|
||||
order: LogsSortOrder;
|
||||
@@ -289,12 +315,22 @@ export interface PreProcessOptions {
|
||||
|
||||
export const preProcessLogs = (
|
||||
logs: LogRowModel[],
|
||||
{ escape, getFieldLinks, order, prettifyJSON, timeZone, virtualization, wrapLogMessage }: PreProcessOptions,
|
||||
{
|
||||
customHighlights,
|
||||
escape,
|
||||
getFieldLinks,
|
||||
order,
|
||||
prettifyJSON,
|
||||
timeZone,
|
||||
virtualization,
|
||||
wrapLogMessage,
|
||||
}: PreProcessOptions,
|
||||
grammar?: Grammar
|
||||
): LogListModel[] => {
|
||||
const orderedLogs = sortLogRows(logs, order);
|
||||
return orderedLogs.map((log) =>
|
||||
preProcessLog(log, {
|
||||
customHighlights,
|
||||
escape,
|
||||
getFieldLinks,
|
||||
grammar,
|
||||
@@ -307,6 +343,7 @@ export const preProcessLogs = (
|
||||
};
|
||||
|
||||
interface PreProcessLogOptions {
|
||||
customHighlights?: CustomHighlight[];
|
||||
escape: boolean;
|
||||
getFieldLinks?: GetFieldLinksFn;
|
||||
grammar?: Grammar;
|
||||
@@ -336,7 +373,7 @@ function countNewLines(log: string, limit = Infinity) {
|
||||
let count = 0;
|
||||
for (let i = 0; i < log.length; ++i) {
|
||||
// No need to iterate further
|
||||
if (count > Infinity) {
|
||||
if (count > limit) {
|
||||
return count;
|
||||
}
|
||||
if (log[i] === '\n') {
|
||||
|
||||
@@ -10006,6 +10006,7 @@
|
||||
"oldest-first": "Sorted by oldest logs first - Click to show newest first",
|
||||
"prettify-json": "Expand JSON logs",
|
||||
"remove-escaping": "Remove escaping",
|
||||
"reset-highlights": "Reset highlights",
|
||||
"resolution-ms": "ms",
|
||||
"resolution-ns": "ns",
|
||||
"scroll-bottom": "Scroll to bottom",
|
||||
@@ -10027,7 +10028,8 @@
|
||||
"disable-highlighting": "Disable highlighting",
|
||||
"download": "Download",
|
||||
"enable-highlighting": "Enable highlighting",
|
||||
"filter-level": "Filter logs result by level"
|
||||
"filter-level": "Filter logs result by level",
|
||||
"reset-highlights": "Clear all custom highlights"
|
||||
},
|
||||
"unwrap-lines": "Unwrap lines",
|
||||
"wrap-lines": "Wrap lines"
|
||||
@@ -10067,6 +10069,7 @@
|
||||
"popover-menu": {
|
||||
"copy": "Copy selection",
|
||||
"disable-menu": "Disable menu",
|
||||
"highlight-occurrences": "Highlight occurrences",
|
||||
"line-contains": "Add as line contains filter",
|
||||
"line-contains-not": "Add as line does not contain filter"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user