Compare commits

...

5 Commits

Author SHA1 Message Date
Oleg Zaytsev
b24a5c79f3 Replace hardcoded HIGHLIGHT_COLOR_COUNT with actual theme palette length
Use useTheme2() hook to dynamically get the palette length instead of
hardcoding it to 50. This ensures the color cycling works correctly
regardless of the actual theme palette size.
2025-12-02 10:56:32 +01:00
Oleg Zaytsev
1756c45e33 Fix TypeScript error in grammar.ts
Use Record<string, GrammarValue> type for dynamic grammar object to allow string indexing without type assertions.
2025-12-01 18:38:34 +01:00
Oleg Zaytsev
9edf23ea68 Fix lint errors
- Use theme.shape.radius.default instead of literal '2px' in LogLine.tsx
- Remove unnecessary type assertion in grammar.ts
2025-12-01 18:31:59 +01:00
Oleg Zaytsev
3d15a07147 Fix CI failures: formatting and i18n extraction
- Run prettier on LogLine.tsx
- Run i18n-extract to update translation strings
2025-12-01 17:31:03 +01:00
Oleg Zaytsev
f0ef66eaa1 Explore: Add custom text highlighting to logs panel
Add ability to select text in log lines and highlight all occurrences
with persistent colors. Highlights are stored in URL state and cycle
through the theme's visualization palette.

- Add CustomHighlight type to ExploreLogsPanelState
- Implement LogListHighlightContext for state management
- Generate custom highlight grammar using Prism.js tokens
- Add "Highlight occurrences" option to popover menu
- Add "Reset highlights" control when highlights exist
- Fix pruneObject to preserve colorIndex: 0 in URL state
2025-12-01 16:50:11 +01:00
16 changed files with 609 additions and 29 deletions

View File

@@ -507,6 +507,7 @@ export type {
ExploreCorrelationHelperData,
ExploreTracePanelState,
ExploreLogsPanelState,
CustomHighlight,
SplitOpenOptions,
SplitOpen,
TraceSearchProps,

View File

@@ -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> {

View File

@@ -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}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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}
/>
)}

View File

@@ -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} />

View File

@@ -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);
});
});

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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
});
});

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -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') {

View File

@@ -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"
},