Outline: Delegate log level filtering to the Logs panel (#106973)

* Content Outline: Depreacte log level filtering

* LogsVolumePanelList: refactor series visibility callback

* Logs Volume: sync filters with panel

* ExploreGraph: add toggleLegend support to remove overrides

* Explore: implement two way level filtering from Panel or Logs Volume

* Remove log

* ExploreGraph: remove buggy ref

* Update test
This commit is contained in:
Matias Chomicki
2025-06-23 11:29:30 +02:00
committed by GitHub
parent 63bc65fa4c
commit 51bd50142a
8 changed files with 129 additions and 216 deletions
@@ -58,7 +58,7 @@ interface Props {
thresholdsStyle?: GraphThresholdsStyleConfig;
eventBus: EventBus;
vizLegendOverrides?: Partial<VizLegendOptions>;
toggleLegendRef?: React.MutableRefObject<(name: string, mode: SeriesVisibilityChangeMode) => void>;
toggleLegendRef?: React.MutableRefObject<(name: string | undefined, mode: SeriesVisibilityChangeMode) => void>;
}
export function ExploreGraph({
@@ -141,27 +141,27 @@ export function ExploreGraph({
const structureRev = useStructureRev(dataWithConfig);
const onHiddenSeriesChangedRef = useRef(onHiddenSeriesChanged);
const previousHiddenFrames = useRef<string[] | undefined>(undefined);
useEffect(() => {
if (onHiddenSeriesChangedRef.current) {
const hiddenFrames: string[] = [];
dataWithConfig.forEach((frame) => {
const allFieldsHidden = frame.fields.map((field) => field.config?.custom?.hideFrom?.viz).every(identity);
if (allFieldsHidden) {
hiddenFrames.push(getFrameDisplayName(frame));
}
});
if (
previousHiddenFrames.current === undefined ||
!isEqual(sortBy(hiddenFrames), sortBy(previousHiddenFrames.current))
) {
previousHiddenFrames.current = hiddenFrames;
onHiddenSeriesChangedRef.current(hiddenFrames);
}
if (!onHiddenSeriesChanged) {
return;
}
}, [dataWithConfig]);
const hiddenFrames: string[] = [];
dataWithConfig.forEach((frame) => {
const allFieldsHidden = frame.fields.map((field) => field.config?.custom?.hideFrom?.viz).every(identity);
if (allFieldsHidden) {
hiddenFrames.push(getFrameDisplayName(frame));
}
});
if (
previousHiddenFrames.current === undefined ||
!isEqual(sortBy(hiddenFrames), sortBy(previousHiddenFrames.current))
) {
previousHiddenFrames.current = hiddenFrames;
onHiddenSeriesChanged(hiddenFrames);
}
}, [dataWithConfig, onHiddenSeriesChanged]);
const panelContext: PanelContext = {
eventsScope: 'explore',
@@ -174,7 +174,14 @@ export function ExploreGraph({
dataLinkPostProcessor,
};
function toggleLegend(name: string, mode: SeriesVisibilityChangeMode) {
function toggleLegend(name: string | undefined, mode: SeriesVisibilityChangeMode) {
if (!name) {
setFieldConfig({
...fieldConfig,
overrides: [],
});
return;
}
setFieldConfig(seriesVisibilityConfigFactory(name, mode, fieldConfig, data));
}
+66 -118
View File
@@ -21,8 +21,6 @@ import {
TimeRange,
LogsDedupStrategy,
LogsSortOrder,
LogLevel,
DataTopic,
CoreApp,
LogsDedupDescription,
rangeUtil,
@@ -31,10 +29,11 @@ import {
DataHoverEvent,
serializeStateToUrlParam,
urlUtil,
LogLevel,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { DataQuery, DataTopic } from '@grafana/schema';
import {
Button,
InlineField,
@@ -47,7 +46,6 @@ import {
Themeable2,
withTheme2,
} from '@grafana/ui';
import { mapMouseEventToMode } from '@grafana/ui/internal';
import store from 'app/core/store';
import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks';
import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows';
@@ -56,7 +54,7 @@ import { LogRows } from 'app/features/logs/components/LogRows';
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';
import { LogList, LogListControlOptions } from 'app/features/logs/components/panel/LogList';
import { isDedupStrategy, isLogsSortOrder } from 'app/features/logs/components/panel/LogListContext';
import { LogLevelColor, dedupLogRows, filterLogLevels } from 'app/features/logs/logsModel';
import { LogLevelColor, dedupLogRows } from 'app/features/logs/logsModel';
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';
@@ -65,7 +63,6 @@ import { getState } from 'app/store/store';
import { ExploreItemState, useDispatch } from 'app/types';
import {
contentOutlineTrackLevelFilter,
contentOutlineTrackPinAdded,
contentOutlineTrackPinClicked,
contentOutlineTrackPinLimitReached,
@@ -202,7 +199,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)
);
const [dedupStrategy, setDedupStrategy] = useState<LogsDedupStrategy>(LogsDedupStrategy.none);
const [hiddenLogLevels, setHiddenLogLevels] = useState<LogLevel[]>([]);
const [logsSortOrder, setLogsSortOrder] = useState<LogsSortOrder>(
store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending
);
@@ -219,12 +215,12 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const previousLoading = usePrevious(loading);
const logsVolumeEventBus = eventBus.newScopedBus('logsvolume', { onlyLocal: false });
const { register, unregister, outlineItems, updateItem, unregisterAllChildren } = useContentOutlineContext() ?? {};
const { register, unregister, outlineItems, updateItem } = useContentOutlineContext() ?? {};
const flipOrderTimer = useRef<number | undefined>(undefined);
const cancelFlippingTimer = useRef<number | undefined>(undefined);
const toggleLegendRef = useRef<(name: string, mode: SeriesVisibilityChangeMode) => void>(() => {});
const toggleLegendRef = useRef<(name: string | undefined, mode: SeriesVisibilityChangeMode) => void>(() => {});
const topLogsRef = useRef<HTMLDivElement>(null);
const logLevelsRef = useRef<LogLevel[] | null>(null);
const [filterLevels, setFilterLevels] = useState<LogLevel[] | undefined>(undefined);
const tableHeight = getLogsTableHeight();
const setWrapperLineWrapStyles = wrapLogMessage || visualisationType === 'table';
@@ -247,62 +243,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
return logsParent?.children?.filter((child) => child.title === PINNED_LOGS_TITLE).length ?? 0;
}, [outlineItems]);
const registerLogLevelsWithContentOutline = useCallback(() => {
const levelsArr = Object.keys(LogLevelColor);
const logVolumeDataFrames = new Set(logsVolumeData?.data);
// TODO remove this once filtering multiple log volumes is supported
const logVolData = logsVolumeData?.data.filter(
(frame: DataFrame) => frame.meta?.dataTopic !== DataTopic.Annotations
);
const grouped = groupBy(logVolData, 'meta.custom.datasourceName');
const numberOfLogVolumes = Object.keys(grouped).length;
// clean up all current log levels
if (unregisterAllChildren) {
unregisterAllChildren((items) => {
const logsParent = items?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root');
return logsParent?.id;
}, 'filter');
}
// check if we have dataFrames that return the same level
const logLevelsArray: LogLevel[] = [];
logVolumeDataFrames.forEach((dataFrame) => {
const { level } = getLogLevelInfo(dataFrame, logsVolumeData?.data ?? []);
logLevelsArray.push(getLogLevelFromKey(level));
});
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);
if (register) {
register({
title: level,
icon: 'gf-logs',
panelId: PINNED_LOGS_PANELID,
level: 'child',
type: 'filter',
highlight: currentLevelSelected && !allLevelsSelected,
onClick: (e: React.MouseEvent) => {
toggleLegendRef.current?.(level, mapMouseEventToMode(e));
contentOutlineTrackLevelFilter(level);
},
ref: null,
color: LogLevelColor[level],
});
}
});
}
}, [logsVolumeData?.data, unregisterAllChildren, logsVolumeEnabled, hiddenLogLevels, register, toggleLegendRef]);
useEffect(() => {
if (getPinnedLogsCount() === PINNED_LOGS_LIMIT) {
setPinLineButtonTooltipTitle(
@@ -348,10 +288,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
setDisplayedFields(displayedFields);
}, [panelState?.logs?.displayedFields]);
useEffect(() => {
registerLogLevelsWithContentOutline();
}, [logsVolumeData?.data, hiddenLogLevels, registerLogLevelsWithContentOutline]);
useUnmount(() => {
if (flipOrderTimer) {
window.clearTimeout(flipOrderTimer.current);
@@ -564,11 +500,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
}
}, []);
const onToggleLogLevel = useCallback((hiddenRawLevels: string[]) => {
const hiddenLogLevels = hiddenRawLevels.map(getLogLevelFromKey);
setHiddenLogLevels(hiddenLogLevels);
}, []);
const onToggleLogsVolumeCollapse = useCallback(
(collapsed: boolean) => {
props.onSetLogsVolumeEnabled(!collapsed);
@@ -758,60 +689,81 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
);
const hasUnescapedContent = useMemo(() => checkUnescapedContent(logRows), [logRows]);
const filteredLogs = useMemo(() => filterRows(logRows, hiddenLogLevels), [hiddenLogLevels, logRows]);
const { dedupedRows, dedupCount } = useMemo(
() => dedupRows(filteredLogs, dedupStrategy),
[dedupStrategy, filteredLogs]
);
const { dedupedRows, dedupCount } = useMemo(() => dedupRows(logRows, dedupStrategy), [dedupStrategy, logRows]);
const navigationRange = useMemo(() => createNavigationRange(logRows), [logRows]);
const infiniteScrollAvailable = useMemo(
() => !logsQueries?.some((query) => 'direction' in query && query.direction === LokiQueryDirection.Scan),
[logsQueries]
);
const visibilityChangedRef = useRef(true);
const onLogOptionsChange = useCallback(
(option: keyof LogListControlOptions, value: string | string[] | boolean) => {
if (option === 'sortOrder' && isLogsSortOrder(value)) {
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 (!allLevels.length) {
// Logs panel is disabled
return;
}
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]);
}
} else if (option === 'dedupStrategy' && isDedupStrategy(value)) {
setDedupStrategy(value);
} else if (option === 'filterLevels' && Array.isArray(value) && logsVolumeEnabled) {
const logVolData =
logsVolumeData?.data.filter((frame: DataFrame) => frame.meta?.dataTopic !== DataTopic.Annotations) ?? [];
const grouped = groupBy(logVolData, 'meta.custom.datasourceName');
const numberOfLogVolumes = Object.keys(grouped).length;
// Not supported
if (numberOfLogVolumes > 1) {
return;
}
const logsVolumeLevels = [
...new Set(
logVolData.map((dataFrame) => {
const { level } = getLogLevelInfo(dataFrame, logVolData);
return getLogLevelFromKey(level);
})
),
];
setFilterLevels((currentFilterLevels: LogLevel[] | undefined) => {
const newLevels = value.map((level) => getLogLevelFromKey(level));
const newLogsVolumeLevels = newLevels.filter((level) => logsVolumeLevels.includes(level));
const prevLogsVolumeLevels = currentFilterLevels?.filter((level) => logsVolumeLevels.includes(level)) ?? [];
if (!newLogsVolumeLevels.length) {
toggleLegendRef.current?.(undefined, SeriesVisibilityChangeMode.ToggleSelection);
return undefined;
}
const addedLevels = newLogsVolumeLevels.filter((newLevel) => !prevLogsVolumeLevels.includes(newLevel));
const removedLevels = prevLogsVolumeLevels.filter((prevLevel) => !newLogsVolumeLevels.includes(prevLevel));
addedLevels.forEach((level) => {
visibilityChangedRef.current = true;
toggleLegendRef.current?.(
level,
!prevLogsVolumeLevels.length
? SeriesVisibilityChangeMode.ToggleSelection
: SeriesVisibilityChangeMode.AppendToSelection
);
});
removedLevels.forEach((level) => {
visibilityChangedRef.current = true;
toggleLegendRef.current?.(level, SeriesVisibilityChangeMode.AppendToSelection);
});
return newLevels;
});
}
},
[hiddenLogLevels, sortOrderChanged]
[logsVolumeData?.data, logsVolumeEnabled, sortOrderChanged]
);
const filterLevels: LogLevel[] | undefined = useMemo(
() =>
!logLevelsRef.current || logLevelsRef.current.length === 0
? undefined
: logLevelsRef.current.filter((level) => hiddenLogLevels.length > 0 && !hiddenLogLevels.includes(level)),
[hiddenLogLevels]
);
const onDisplayedSeriesChanged = useCallback((levels: string[]) => {
if (visibilityChangedRef.current) {
visibilityChangedRef.current = false;
return;
}
setFilterLevels(levels.map((level) => getLogLevelFromKey(level)));
}, []);
return (
<>
@@ -843,7 +795,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
timeZone={timeZone}
splitOpen={splitOpen}
onLoadLogsVolume={loadLogsVolumeData}
onHiddenSeriesChanged={onToggleLogLevel}
onDisplayedSeriesChanged={onDisplayedSeriesChanged}
eventBus={logsVolumeEventBus}
onClose={() => onToggleLogsVolumeCollapse(true)}
/>
@@ -1315,10 +1267,6 @@ const dedupRows = (logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) =>
return { dedupedRows, dedupCount };
};
const filterRows = (logRows: LogRowModel[], hiddenLogLevels: string[]) => {
return filterLogLevels(logRows, new Set(hiddenLogLevels));
};
const createNavigationRange = (logRows: LogRowModel[]): { from: number; to: number } | undefined => {
if (!logRows || logRows.length === 0) {
return undefined;
@@ -31,7 +31,9 @@ type Props = {
onHiddenSeriesChanged: (hiddenSeries: string[]) => void;
eventBus: EventBus;
annotations: DataFrame[];
toggleLegendRef?: React.MutableRefObject<(name: string, mode: SeriesVisibilityChangeMode) => void> | undefined;
toggleLegendRef?:
| React.MutableRefObject<(name: string | undefined, mode: SeriesVisibilityChangeMode) => void>
| undefined;
};
export function LogsVolumePanel(props: Props) {
@@ -22,7 +22,7 @@ function renderPanel(logsVolumeData?: DataQueryResponse, onLoadLogsVolume = () =
onUpdateTimeRange={() => {}}
logsVolumeData={logsVolumeData}
onLoadLogsVolume={onLoadLogsVolume}
onHiddenSeriesChanged={() => null}
onDisplayedSeriesChanged={() => null}
eventBus={new EventBusSrv()}
/>
);
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { flatten, groupBy, mapValues, sortBy } from 'lodash';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import * as React from 'react';
import {
@@ -10,8 +10,10 @@ import {
DataTopic,
dateTime,
EventBus,
getFrameDisplayName,
GrafanaTheme2,
LoadingState,
shallowCompare,
SplitOpen,
TimeRange,
TimeZone,
@@ -34,10 +36,10 @@ type Props = {
width: number;
onUpdateTimeRange: (timeRange: AbsoluteTimeRange) => void;
onLoadLogsVolume: () => void;
onHiddenSeriesChanged: (hiddenSeries: string[]) => void;
onDisplayedSeriesChanged: (series: string[]) => void;
eventBus: EventBus;
onClose?(): void;
toggleLegendRef?: React.MutableRefObject<(name: string, mode: SeriesVisibilityChangeMode) => void>;
toggleLegendRef?: React.MutableRefObject<(name: string | undefined, mode: SeriesVisibilityChangeMode) => void>;
};
export const LogsVolumePanelList = ({
@@ -46,7 +48,7 @@ export const LogsVolumePanelList = ({
onUpdateTimeRange,
width,
onLoadLogsVolume,
onHiddenSeriesChanged,
onDisplayedSeriesChanged,
eventBus,
splitOpen,
timeZone,
@@ -96,6 +98,25 @@ export const LogsVolumePanelList = ({
const to = dateTime(Math.min(absoluteRange.to, allLogsVolumeMaximumRange.to));
const visibleRange: TimeRange = { from, to, raw: { from, to } };
const handleHiddenSeriesChanged = useCallback(
(hiddenSeries: string[]) => {
// Not supported
if (numberOfLogVolumes > 1) {
return;
}
const allLevels = [
...new Set(
Object.values(logVolumes)
.map((series) => series.map((dataFrame) => getFrameDisplayName(dataFrame)))
.flat()
),
];
const displayedLevels = allLevels.filter((level) => !hiddenSeries.includes(level));
onDisplayedSeriesChanged(shallowCompare(allLevels, displayedLevels) ? [] : displayedLevels);
},
[logVolumes, numberOfLogVolumes, onDisplayedSeriesChanged]
);
if (logsVolumeData?.state === LoadingState.Loading) {
return (
<span>
@@ -185,7 +206,7 @@ export const LogsVolumePanelList = ({
splitOpen={splitOpen}
onLoadLogsVolume={onLoadLogsVolume}
// TODO: Support filtering level from multiple log levels
onHiddenSeriesChanged={numberOfLogVolumes > 1 ? () => {} : onHiddenSeriesChanged}
onHiddenSeriesChanged={numberOfLogVolumes > 1 ? () => {} : handleHiddenSeriesChanged}
eventBus={eventBus}
annotations={annotations}
/>
@@ -261,10 +261,13 @@ export const LogListContextProvider = ({
if (filterLevels === undefined) {
return;
}
if (!shallowCompare(logListState.filterLevels, filterLevels)) {
setLogListState({ ...logListState, filterLevels });
}
}, [filterLevels, logListState]);
setLogListState((logListState) => {
if (!shallowCompare(logListState.filterLevels, filterLevels)) {
return { ...logListState, filterLevels };
}
return logListState;
});
}, [filterLevels]);
useEffect(() => {
setLogListState((logListState) => ({ ...logListState, fontSize }));
@@ -31,7 +31,6 @@ import {
COMMON_LABELS,
dataFrameToLogsModel,
dedupLogRows,
filterLogLevels,
getSeriesProperties,
LIMIT_LABEL,
TOTAL_LABEL,
@@ -169,63 +168,6 @@ describe('dedupLogRows()', () => {
});
});
describe('filterLogLevels()', () => {
test('should correctly filter out log levels', () => {
const rows = [
{
entry: 'DEBUG 1',
logLevel: LogLevel.debug,
},
{
entry: 'ERROR 1',
logLevel: LogLevel.error,
},
{
entry: 'TRACE 1',
logLevel: LogLevel.trace,
},
] as LogRowModel[];
const filteredLogs = filterLogLevels(rows, new Set([LogLevel.debug]));
expect(filteredLogs.length).toBe(2);
expect(filteredLogs).toEqual([
{ entry: 'ERROR 1', logLevel: 'error' },
{ entry: 'TRACE 1', logLevel: 'trace' },
]);
});
test('should correctly filter out log levels and then deduplicate', () => {
const rows = [
{
entry: 'DEBUG 1',
logLevel: LogLevel.debug,
},
{
entry: 'DEBUG 2',
logLevel: LogLevel.debug,
},
{
entry: 'DEBUG 2',
logLevel: LogLevel.debug,
},
{
entry: 'ERROR 1',
logLevel: LogLevel.error,
},
{
entry: 'TRACE 1',
logLevel: LogLevel.trace,
},
] as LogRowModel[];
const filteredLogs = filterLogLevels(rows, new Set([LogLevel.error]));
const deduplicatedLogs = dedupLogRows(filteredLogs, LogsDedupStrategy.exact);
expect(deduplicatedLogs.length).toBe(3);
expect(deduplicatedLogs).toEqual([
{ duplicates: 0, entry: 'DEBUG 1', logLevel: 'debug' },
{ duplicates: 1, entry: 'DEBUG 2', logLevel: 'debug' },
{ duplicates: 0, entry: 'TRACE 1', logLevel: 'trace' },
]);
});
});
const emptyLogsModel = {
hasUniqueLabels: false,
rows: [],
-10
View File
@@ -104,16 +104,6 @@ export function dedupLogRows(rows: LogRowModel[], strategy?: LogsDedupStrategy):
}, []);
}
export function filterLogLevels(logRows: LogRowModel[], hiddenLogLevels: Set<string>): LogRowModel[] {
if (hiddenLogLevels.size === 0) {
return logRows;
}
return logRows.filter((row: LogRowModel) => {
return !hiddenLogLevels.has(row.logLevel);
});
}
interface Series {
lastTs: number | null;
datapoints: Array<[number, number]>;