Explore: Logs in content outline (#86431)

* wip filtering

* Remove corresponding children from parentlessItem ref on register

* wip filtering

* Remove corresponding children from parentlessItem ref on register

* Highlighting filters

* OMG

* Handle updating highlight field inside context

* Toggling legend from the content outline

* Support multiple selects for log filters in content outline

* Remove commented out code

* Fix an issue with loki datasource; use same datastructure for logsVolume in Logs and LogVolume panel

* Fix registering logic

* cleanup

* Don't register outline items when having multiple log volume levels

* Remove checking mergeSingleChild logic because in context because ContentOutline does it

* Fix logic so it works for elasticsearch

* Better logic

* Add log color and sort

* Show items at full opacity

* Remove commented sort

* Do not highlight if all are selected, and highlight = 100% opacity, active = 30%

* dont auto collapse after filter unselect

* Remove console logs

* No partial opacity, add height for consistent display

---------

Co-authored-by: Piotr Jamroz <pm.jamroz@gmail.com>
Co-authored-by: Kristina Durivage <kristina.durivage@grafana.com>
This commit is contained in:
Haris Rozajac
2024-05-08 14:58:37 +02:00
committed by GitHub
parent ef37b78631
commit b383cafd35
10 changed files with 357 additions and 114 deletions
@@ -9,6 +9,31 @@ import { useStyles2, PanelContainer, CustomScrollbar } from '@grafana/ui';
import { ContentOutlineItemContextProps, useContentOutlineContext } from './ContentOutlineContext';
import { ContentOutlineItemButton } from './ContentOutlineItemButton';
function scrollableChildren(item: ContentOutlineItemContextProps) {
return item.children?.filter((child) => child.type !== 'filter') || [];
}
type SectionsExpanded = Record<string, boolean>;
function shouldBeActive(
item: ContentOutlineItemContextProps,
activeSectionId: string,
activeSectionChildId: string | undefined,
sectionsExpanded: SectionsExpanded
) {
const isAnActiveParent = activeSectionId === item.id;
const isAnActiveChild = activeSectionChildId === item.id;
const isCollapsed = !sectionsExpanded[item.id];
const containsScrollableChildren = scrollableChildren(item).length > 0;
const anyChildActive = isChildActive(item, activeSectionChildId) && !sectionsExpanded[item.id];
if (containsScrollableChildren) {
return isCollapsed && (isAnActiveParent || anyChildActive);
} else {
return isAnActiveParent || isAnActiveChild;
}
}
export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | undefined; panelId: string }) {
const [contentOutlineExpanded, toggleContentOutlineExpanded] = useToggle(false);
const styles = useStyles2(getStyles, contentOutlineExpanded);
@@ -80,7 +105,7 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
}
// Check children
const activeChild = item.children?.find((child) => {
const activeChild = scrollableChildren(item).find((child) => {
const offsetTop = child.customTopOffset || 0;
let childTop = child?.ref?.getBoundingClientRect().top;
return childTop && childTop >= offsetTop;
@@ -95,15 +120,21 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
if (activeItem) {
setActiveSectionId(activeItem.id);
setActiveSectionChildId(undefined);
setSectionsExpanded((prev) => ({
...prev,
[item.id]: false,
}));
break;
}
}
}, [outlineItems, verticalScroll]);
const activateFilter = (filterId: string) => {
const activeParent = outlineItems.find((item) => {
return item.children?.find((child) => child.id === filterId);
});
if (activeParent) {
scrollIntoView(activeParent.ref, activeParent.panelId, activeParent.customTopOffset);
}
};
return (
<PanelContainer className={styles.wrapper} id={panelId}>
<CustomScrollbar>
@@ -119,67 +150,74 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
aria-expanded={contentOutlineExpanded}
/>
{outlineItems.map((item) => (
<React.Fragment key={item.id}>
<ContentOutlineItemButton
key={item.id}
title={contentOutlineExpanded ? item.title : undefined}
contentOutlineExpanded={contentOutlineExpanded}
className={cx(styles.buttonStyles, {
[styles.justifyCenter]: !contentOutlineExpanded,
[styles.sectionHighlighter]: isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
})}
indentStyle={cx({
[styles.indentRoot]: outlineItemsShouldIndent && item.children?.length === 0,
[styles.sectionHighlighter]:
isChildActive(item, activeSectionChildId) && !contentOutlineExpanded && sectionsExpanded[item.id],
})}
icon={item.icon}
onClick={() => scrollIntoView(item.ref, item.panelId)}
tooltip={item.title}
collapsible={isCollapsible(item)}
collapsed={!sectionsExpanded[item.id]}
toggleCollapsed={() => toggleSection(item.id)}
isActive={
(isChildActive(item, activeSectionChildId) && !sectionsExpanded[item.id]) ||
(activeSectionId === item.id && !sectionsExpanded[item.id])
}
sectionId={item.id}
/>
<div id={item.id} data-testid={`section-wrapper-${item.id}`}>
{item.children &&
(!item.mergeSingleChild || item.children.length !== 1) &&
sectionsExpanded[item.id] &&
item.children.map((child, i) => (
<div key={child.id} className={styles.itemWrapper}>
{contentOutlineExpanded && (
<div
className={cx(styles.itemConnector, {
[styles.firstItemConnector]: i === 0,
[styles.lastItemConnector]: i === (item.children?.length || 0) - 1,
{outlineItems.map((item) => {
return (
<React.Fragment key={item.id}>
<ContentOutlineItemButton
key={item.id}
title={contentOutlineExpanded ? item.title : undefined}
contentOutlineExpanded={contentOutlineExpanded}
className={cx(styles.buttonStyles, {
[styles.justifyCenter]: !contentOutlineExpanded,
[styles.sectionHighlighter]: isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
})}
indentStyle={cx({
[styles.indentRoot]: !isCollapsible(item) && outlineItemsShouldIndent,
[styles.sectionHighlighter]:
isChildActive(item, activeSectionChildId) && !contentOutlineExpanded && sectionsExpanded[item.id],
})}
icon={item.icon}
onClick={() => scrollIntoView(item.ref, item.panelId)}
tooltip={item.title}
collapsible={isCollapsible(item)}
collapsed={!sectionsExpanded[item.id]}
toggleCollapsed={() => toggleSection(item.id)}
isActive={shouldBeActive(item, activeSectionId, activeSectionChildId, sectionsExpanded)}
sectionId={item.id}
color={item.color}
/>
<div id={item.id} data-testid={`section-wrapper-${item.id}`}>
{item.children &&
isCollapsible(item) &&
sectionsExpanded[item.id] &&
item.children.map((child, i) => (
<div key={child.id} className={styles.itemWrapper}>
{contentOutlineExpanded && (
<div
className={cx(styles.itemConnector, {
[styles.firstItemConnector]: i === 0,
[styles.lastItemConnector]: i === (item.children?.length || 0) - 1,
})}
/>
)}
<ContentOutlineItemButton
key={child.id}
title={contentOutlineExpanded ? child.title : undefined}
contentOutlineExpanded={contentOutlineExpanded}
icon={contentOutlineExpanded ? undefined : item.icon}
className={cx(styles.buttonStyles, {
[styles.justifyCenter]: !contentOutlineExpanded,
[styles.sectionHighlighter]:
isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
})}
indentStyle={styles.indentChild}
onClick={(e) => {
child.type === 'filter'
? activateFilter(child.id)
: scrollIntoView(child.ref, child.panelId, child.customTopOffset);
child.onClick?.(e);
}}
tooltip={child.title}
isActive={shouldBeActive(child, activeSectionId, activeSectionChildId, sectionsExpanded)}
extraHighlight={child.highlight}
color={child.color}
/>
)}
<ContentOutlineItemButton
key={child.id}
title={contentOutlineExpanded ? child.title : undefined}
contentOutlineExpanded={contentOutlineExpanded}
icon={contentOutlineExpanded ? undefined : item.icon}
className={cx(styles.buttonStyles, {
[styles.justifyCenter]: !contentOutlineExpanded,
[styles.sectionHighlighter]:
isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
})}
indentStyle={styles.indentChild}
onClick={() => scrollIntoView(child.ref, child.panelId, child.customTopOffset)}
tooltip={child.title}
isActive={activeSectionChildId === child.id}
/>
</div>
))}
</div>
</React.Fragment>
))}
</div>
))}
</div>
</React.Fragment>
);
})}
</div>
</CustomScrollbar>
</PanelContainer>
@@ -1,11 +1,12 @@
import { uniqueId } from 'lodash';
import React, { useState, useContext, createContext, ReactNode, useCallback, useRef, useEffect } from 'react';
import { ContentOutlineItemBaseProps } from './ContentOutlineItem';
import { ContentOutlineItemBaseProps, ITEM_TYPES } from './ContentOutlineItem';
export interface ContentOutlineItemContextProps extends ContentOutlineItemBaseProps {
id: string;
ref: HTMLElement | null;
color?: string;
children?: ContentOutlineItemContextProps[];
}
@@ -15,6 +16,7 @@ export interface ContentOutlineContextProps {
outlineItems: ContentOutlineItemContextProps[];
register: RegisterFunction;
unregister: (id: string) => void;
unregisterAllChildren: (parentId: string, childType: ITEM_TYPES) => void;
updateOutlineItems: (newItems: ContentOutlineItemContextProps[]) => void;
}
@@ -31,7 +33,7 @@ interface ParentlessItems {
[panelId: string]: ContentOutlineItemContextProps[];
}
const ContentOutlineContext = createContext<ContentOutlineContextProps | undefined>(undefined);
export const ContentOutlineContext = createContext<ContentOutlineContextProps | undefined>(undefined);
export function ContentOutlineContextProvider({ children, refreshDependencies }: ContentOutlineContextProviderProps) {
const [outlineItems, setOutlineItems] = useState<ContentOutlineItemContextProps[]>([]);
@@ -42,14 +44,28 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }:
setOutlineItems((prevItems) => {
if (outlineItem.level === 'root') {
const mergeSingleChild = checkMergeSingleChild(parentlessItemsRef, outlineItem);
const parentlessItems = parentlessItemsRef.current[outlineItem.panelId] || [];
// if item has children in parentlessItemsRef and they are filters,
// modify each child to have ref = outlineItem.ref
// so that clicking on the filter will also bring the parent item into view
if (parentlessItems.length > 0) {
parentlessItemsRef.current[outlineItem.panelId].forEach((item) => {
if (item.type === 'filter') {
item.ref = outlineItem.ref;
}
});
}
// remove children from parentlessItemsRef
parentlessItemsRef.current[outlineItem.panelId] = [];
const updatedItems = [
...prevItems,
{
...outlineItem,
id,
children: parentlessItemsRef.current[outlineItem.panelId] || [],
mergeSingleChild,
children: parentlessItems,
},
];
@@ -57,6 +73,24 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }:
}
if (outlineItem.level === 'child') {
let siblingWithSameTitleFound = false;
// items with type filter should not have siblings with the same title
// look at all parentless items and check if there is a sibling with the same title
Object.keys(parentlessItemsRef.current).forEach((key) => {
const siblingWithSameTitle = parentlessItemsRef.current[key].find(
(item) =>
item.title === outlineItem.title && outlineItem.type === 'filter' && outlineItem.panelId === item.panelId
);
if (siblingWithSameTitle) {
siblingWithSameTitleFound = true;
return;
}
});
if (siblingWithSameTitleFound) {
return [...prevItems];
}
const parentIndex = prevItems.findIndex(
(item) => item.panelId === outlineItem.panelId && item.level === 'root'
);
@@ -83,14 +117,36 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }:
const newItems = [...prevItems];
const parent = { ...newItems[parentIndex] };
const childrenUpdated = [...(parent.children || []), { ...outlineItem, id }];
// look at all registered items inside items parent and check if there is
// a filter sibling with the same title
const siblingWithSameTitle = parent.children?.find(
(item) =>
item.title === outlineItem.title && outlineItem.type === 'filter' && outlineItem.panelId === item.panelId
);
// check if sibling's highlight property has updated
if (siblingWithSameTitle && siblingWithSameTitle.highlight !== outlineItem.highlight) {
parent.children?.map((child) => {
if (child.title === siblingWithSameTitle?.title) {
child.highlight = outlineItem.highlight;
}
});
return [...prevItems];
} else if (siblingWithSameTitle) {
return [...prevItems];
}
let ref = outlineItem.ref;
if (outlineItem.type === 'filter') {
ref = parent.ref;
}
const childrenUpdated = [...(parent.children || []), { ...outlineItem, id, ref }];
childrenUpdated.sort(sortElementsByDocumentPosition);
const mergeSingleChild = checkMergeSingleChild(parentlessItemsRef, parent);
newItems[parentIndex] = {
...parent,
children: childrenUpdated,
mergeSingleChild,
};
return newItems;
@@ -119,6 +175,17 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }:
setOutlineItems(newItems);
}, []);
const unregisterAllChildren = useCallback((parentId: string, childType: ITEM_TYPES) => {
setOutlineItems((prevItems) =>
prevItems.map((item) => {
if (item.id === parentId) {
item.children = item.children?.filter((child) => child.type !== childType);
}
return item;
})
);
}, []);
useEffect(() => {
setOutlineItems((prevItems) => {
const newItems = [...prevItems];
@@ -130,7 +197,9 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }:
}, [refreshDependencies]);
return (
<ContentOutlineContext.Provider value={{ outlineItems, register, unregister, updateOutlineItems }}>
<ContentOutlineContext.Provider
value={{ outlineItems, register, unregister, updateOutlineItems, unregisterAllChildren }}
>
{children}
</ContentOutlineContext.Provider>
);
@@ -148,16 +217,6 @@ export function sortElementsByDocumentPosition(a: ContentOutlineItemContextProps
return 0;
}
function checkMergeSingleChild(
parentlessItemsRef: React.MutableRefObject<ParentlessItems>,
outlineItem: Omit<ContentOutlineItemContextProps, 'id'>
) {
const children = parentlessItemsRef.current[outlineItem.panelId] || [];
const mergeSingleChild = children.length === 1 && outlineItem.mergeSingleChild;
return mergeSingleChild;
}
export function useContentOutlineContext() {
return useContext(ContentOutlineContext);
}
@@ -4,6 +4,8 @@ import { useContentOutlineContext } from './ContentOutlineContext';
type INDENT_LEVELS = 'root' | 'child';
export type ITEM_TYPES = 'scrollIntoView' | 'filter';
export interface ContentOutlineItemBaseProps {
panelId: string;
title: string;
@@ -25,6 +27,14 @@ export interface ContentOutlineItemBaseProps {
* because user can navigate to the query row by navigating to the queries container.
*/
mergeSingleChild?: boolean;
// callback that is called when the item is clicked
// need this for filtering logs
onClick?: (e: React.MouseEvent) => void;
type?: ITEM_TYPES;
/**
* Client can additionally mark filter actions as highlighted
*/
highlight?: boolean;
}
interface ContentOutlineItemProps extends ContentOutlineItemBaseProps {
@@ -41,6 +51,8 @@ export function ContentOutlineItem({
className,
level = 'root',
mergeSingleChild,
type = 'scrollIntoView',
onClick,
}: ContentOutlineItemProps) {
const { register, unregister } = useContentOutlineContext() ?? {};
const ref = useRef(null);
@@ -59,11 +71,12 @@ export function ContentOutlineItem({
customTopOffset: customTopOffset,
level: level,
mergeSingleChild,
type,
});
// When the component unmounts, unregister it using its unique ID.
return () => unregister(id);
}, [panelId, title, icon, customTopOffset, level, mergeSingleChild, register, unregister]);
}, [panelId, title, icon, customTopOffset, level, mergeSingleChild, register, unregister, type, onClick]);
return (
<div className={className} ref={ref}>
@@ -2,7 +2,7 @@ import { cx, css } from '@emotion/css';
import React, { ButtonHTMLAttributes, useEffect, useRef, useState } from 'react';
import { IconName, isIconName, GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2, Tooltip } from '@grafana/ui';
import { Icon, Tooltip, useTheme2 } from '@grafana/ui';
import { TooltipPlacement } from '@grafana/ui/src/components/Tooltip';
type CommonProps = {
@@ -16,8 +16,10 @@ type CommonProps = {
collapsible?: boolean;
collapsed?: boolean;
isActive?: boolean;
extraHighlight?: boolean;
sectionId?: string;
toggleCollapsed?: () => void;
color?: string;
};
export type ContentOutlineItemButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
@@ -33,11 +35,14 @@ export function ContentOutlineItemButton({
collapsible,
collapsed,
isActive,
extraHighlight,
sectionId,
toggleCollapsed,
color,
...rest
}: ContentOutlineItemButtonProps) {
const styles = useStyles2(getStyles);
const theme = useTheme2();
const styles = getStyles(theme, color);
const buttonStyles = cx(styles.button, className);
@@ -66,6 +71,7 @@ export function ContentOutlineItemButton({
<button
className={cx(buttonStyles, {
[styles.active]: isActive,
[styles.extraHighlight]: extraHighlight,
})}
aria-label={tooltip}
{...rest}
@@ -104,7 +110,7 @@ function OutlineIcon({ icon }: { icon: IconName | React.ReactNode }) {
return icon;
}
const getStyles = (theme: GrafanaTheme2) => {
const getStyles = (theme: GrafanaTheme2, color?: string) => {
return {
buttonContainer: css({
position: 'relative',
@@ -153,9 +159,30 @@ const getStyles = (theme: GrafanaTheme2) => {
borderTopRightRadius: theme.shape.radius.default,
borderBottomRightRadius: theme.shape.radius.default,
position: 'relative',
height: theme.spacing(theme.components.height.md),
'&::before': {
backgroundImage: theme.colors.gradients.brandVertical,
backgroundImage: color !== undefined ? 'none' : theme.colors.gradients.brandVertical,
backgroundColor: color !== undefined ? color : 'none',
borderRadius: theme.shape.radius.default,
content: '" "',
display: 'block',
height: '100%',
position: 'absolute',
transform: 'translateX(-50%)',
width: theme.spacing(0.5),
left: '2px',
},
}),
extraHighlight: css({
backgroundColor: theme.colors.background.secondary,
borderTopRightRadius: theme.shape.radius.default,
borderBottomRightRadius: theme.shape.radius.default,
position: 'relative',
'&::before': {
backgroundImage: color !== undefined ? 'none' : theme.colors.gradients.brandVertical,
backgroundColor: color !== undefined ? color : 'none',
borderRadius: theme.shape.radius.default,
content: '" "',
display: 'block',
@@ -6,25 +6,25 @@ import {
AbsoluteTimeRange,
applyFieldOverrides,
createFieldConfigRegistry,
DashboardCursorSync,
DataFrame,
dateTime,
EventBus,
FieldColorModeId,
FieldConfigSource,
getFrameDisplayName,
LoadingState,
SplitOpen,
ThresholdsConfig,
DashboardCursorSync,
EventBus,
} from '@grafana/data';
import { PanelRenderer } from '@grafana/runtime';
import {
GraphDrawStyle,
LegendDisplayMode,
TooltipDisplayMode,
SortOrder,
GraphThresholdsStyleConfig,
LegendDisplayMode,
SortOrder,
TimeZone,
TooltipDisplayMode,
VizLegendOptions,
} from '@grafana/schema';
import { PanelContext, PanelContextProvider, SeriesVisibilityChangeMode, useTheme2 } from '@grafana/ui';
@@ -58,6 +58,7 @@ interface Props {
thresholdsStyle?: GraphThresholdsStyleConfig;
eventBus: EventBus;
vizLegendOverrides?: Partial<VizLegendOptions>;
toggleLegendRef?: React.MutableRefObject<(name: string, mode: SeriesVisibilityChangeMode) => void>;
}
export function ExploreGraph({
@@ -79,6 +80,7 @@ export function ExploreGraph({
thresholdsStyle,
eventBus,
vizLegendOverrides,
toggleLegendRef,
}: Props) {
const theme = useTheme2();
const previousTimeRange = usePrevious(absoluteRange);
@@ -176,6 +178,14 @@ export function ExploreGraph({
dataLinkPostProcessor,
};
function toggleLegend(name: string, mode: SeriesVisibilityChangeMode) {
setFieldConfig(seriesVisibilityConfigFactory(name, mode, fieldConfig, data));
}
if (toggleLegendRef) {
toggleLegendRef.current = toggleLegend;
}
const panelOptions: TimeSeriesOptions = useMemo(
() => ({
tooltip: { mode: tooltipDisplayMode, sort: SortOrder.None },
+82 -5
View File
@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import { capitalize } from 'lodash';
import { capitalize, groupBy } from 'lodash';
import memoizeOne from 'memoize-one';
import React, { createRef, PureComponent } from 'react';
@@ -32,7 +32,7 @@ import {
urlUtil,
} from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
import { DataQuery, DataTopic, TimeZone } from '@grafana/schema';
import {
Button,
InlineField,
@@ -40,19 +40,22 @@ import {
InlineSwitch,
PanelChrome,
RadioButtonGroup,
SeriesVisibilityChangeMode,
Themeable2,
withTheme2,
} from '@grafana/ui';
import { mapMouseEventToMode } from '@grafana/ui/src/components/VizLegend/utils';
import store from 'app/core/store';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
import { getLogLevelFromKey } from 'app/features/logs/utils';
import { getLogLevel, getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils';
import { dispatch, getState } from 'app/store/store';
import { ExploreItemState } from '../../../types';
import { LogRows } from '../../logs/components/LogRows';
import { LogRowContextModal } from '../../logs/components/log-context/LogRowContextModal';
import { dedupLogRows, filterLogLevels } from '../../logs/logsModel';
import { dedupLogRows, filterLogLevels, LogLevelColor } from '../../logs/logsModel';
import { ContentOutlineContext } from '../ContentOutline/ContentOutlineContext';
import { getUrlStateFromPaneState } from '../hooks/useStateSync';
import { changePanelState } from '../state/explorePane';
@@ -158,6 +161,11 @@ class UnthemedLogs extends PureComponent<Props, State> {
cancelFlippingTimer?: number;
topLogsRef = createRef<HTMLDivElement>();
logsVolumeEventBus: EventBus;
static contextType = ContentOutlineContext;
declare context: React.ContextType<typeof ContentOutlineContext>;
// @ts-ignore
private toggleLegendRef: React.MutableRefObject<(name: string, mode: SeriesVisibilityChangeMode) => void> =
React.createRef();
state: State = {
showLabels: store.getBool(SETTINGS_KEYS.showLabels, false),
@@ -182,6 +190,10 @@ class UnthemedLogs extends PureComponent<Props, State> {
this.logsVolumeEventBus = props.eventBus.newScopedBus('logsvolume', { onlyLocal: false });
}
componentDidMount(): void {
this.registerLogLevelsWithContentOutline();
}
componentWillUnmount() {
if (this.flipOrderTimer) {
window.clearTimeout(this.flipOrderTimer);
@@ -209,6 +221,54 @@ class UnthemedLogs extends PureComponent<Props, State> {
}
}
registerLogLevelsWithContentOutline = () => {
const levelsArr = Object.keys(LogLevelColor);
const logVolumeDataFrames = new Set(this.props.logsVolumeData?.data);
// TODO remove this once filtering multiple log volumes is supported
const numberOfLogVolumes = this.getNumberOfLogVolumes();
// clean up all current log levels
const logsParent = this.context?.outlineItems.find((item) => item.panelId === 'Logs' && item.level === 'root');
if (logsParent) {
this.context?.unregisterAllChildren(logsParent.id, 'filter');
}
// check if we have dataFrames that return the same level
const logLevelsArray: Array<{ levelStr: string; logLevel: LogLevel }> = [];
logVolumeDataFrames.forEach((dataFrame) => {
const { level } = getLogLevelInfo(dataFrame);
logLevelsArray.push({ levelStr: level, logLevel: getLogLevel(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 logLevels = new Set(sortedLLArray);
if (logLevels.size > 1 && this.props.logsVolumeEnabled && numberOfLogVolumes === 1) {
logLevels.forEach((level) => {
const allLevelsSelected = this.state.hiddenLogLevels.length === 0;
const currentLevelSelected = !this.state.hiddenLogLevels.find((hiddenLevel) => hiddenLevel === level.levelStr);
this.context?.register({
title: level.levelStr,
icon: 'gf-logs',
panelId: 'Logs',
level: 'child',
type: 'filter',
highlight: currentLevelSelected && !allLevelsSelected,
onClick: (e: React.MouseEvent) => {
this.toggleLegendRef.current?.(level.levelStr, mapMouseEventToMode(e));
},
ref: null,
color: LogLevelColor[level.logLevel],
});
});
}
};
updatePanelState = (logsPanelState: Partial<ExploreLogsPanelState>) => {
const state: ExploreItemState | undefined = getState().explore.panes[this.props.exploreId];
if (state?.panelsState) {
@@ -224,7 +284,16 @@ class UnthemedLogs extends PureComponent<Props, State> {
}
};
componentDidUpdate(prevProps: Readonly<Props>): void {
getNumberOfLogVolumes() {
const data = this.props.logsVolumeData?.data.filter(
(frame: DataFrame) => frame.meta?.dataTopic !== DataTopic.Annotations
);
const grouped = groupBy(data, 'meta.custom.datasourceName');
const numberOfLogVolumes = Object.keys(grouped).length;
return numberOfLogVolumes;
}
componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>): void {
if (this.props.loading && !prevProps.loading && this.props.panelState?.logs?.id) {
// loading stopped, so we need to remove any permalinked log lines
delete this.props.panelState.logs.id;
@@ -243,6 +312,13 @@ class UnthemedLogs extends PureComponent<Props, State> {
});
store.set(visualisationTypeKey, visualisationType);
}
if (
prevProps.logsVolumeData?.data !== this.props.logsVolumeData?.data ||
prevState.hiddenLogLevels !== this.state.hiddenLogLevels
) {
this.registerLogLevelsWithContentOutline();
}
}
onLogRowHover = (row?: LogRowModel) => {
@@ -654,6 +730,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
>
{logsVolumeEnabled && (
<LogsVolumePanelList
toggleLegendRef={this.toggleLegendRef}
absoluteRange={absoluteRange}
width={width}
logsVolumeData={logsVolumeData}
@@ -12,7 +12,7 @@ import {
DataFrame,
} from '@grafana/data';
import { TimeZone } from '@grafana/schema';
import { Icon, Tooltip, TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui';
import { Icon, SeriesVisibilityChangeMode, Tooltip, TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui';
import { getLogsVolumeDataSourceInfo, isLogsVolumeLimited } from '../../logs/utils';
import { ExploreGraph } from '../Graph/ExploreGraph';
@@ -29,10 +29,19 @@ type Props = {
onHiddenSeriesChanged: (hiddenSeries: string[]) => void;
eventBus: EventBus;
annotations: DataFrame[];
toggleLegendRef?: React.MutableRefObject<(name: string, mode: SeriesVisibilityChangeMode) => void> | undefined;
};
export function LogsVolumePanel(props: Props) {
const { width, timeZone, splitOpen, onUpdateTimeRange, onHiddenSeriesChanged, allLogsVolumeMaximum } = props;
const {
width,
timeZone,
splitOpen,
onUpdateTimeRange,
onHiddenSeriesChanged,
allLogsVolumeMaximum,
toggleLegendRef,
} = props;
const theme = useTheme2();
const styles = useStyles2(getStyles);
const spacing = parseInt(theme.spacing(2).slice(0, -2), 10);
@@ -68,6 +77,7 @@ export function LogsVolumePanel(props: Props) {
return (
<div style={{ height }} className={styles.contentContainer}>
<ExploreGraph
toggleLegendRef={toggleLegendRef}
vizLegendOverrides={{
calcs: ['sum'],
}}
@@ -13,7 +13,7 @@ import {
SplitOpen,
TimeZone,
} from '@grafana/data';
import { Button, InlineField, Alert, useStyles2 } from '@grafana/ui';
import { Button, InlineField, Alert, useStyles2, SeriesVisibilityChangeMode } from '@grafana/ui';
import { mergeLogsVolumeDataFrames, isLogsVolumeLimited, getLogsVolumeMaximumRange } from '../../logs/utils';
import { SupplementaryResultError } from '../SupplementaryResultError';
@@ -32,6 +32,7 @@ type Props = {
onHiddenSeriesChanged: (hiddenSeries: string[]) => void;
eventBus: EventBus;
onClose?(): void;
toggleLegendRef?: React.MutableRefObject<(name: string, mode: SeriesVisibilityChangeMode) => void>;
};
export const LogsVolumePanelList = ({
@@ -45,6 +46,7 @@ export const LogsVolumePanelList = ({
splitOpen,
timeZone,
onClose,
toggleLegendRef,
}: Props) => {
const {
logVolumes,
@@ -121,6 +123,7 @@ export const LogsVolumePanelList = ({
const logsVolumeData = { data: logVolumes[name] };
return (
<LogsVolumePanel
toggleLegendRef={toggleLegendRef}
key={index}
absoluteRange={visibleRange}
allLogsVolumeMaximum={allLogsVolumeMaximumValue}
+1 -1
View File
@@ -52,8 +52,8 @@ export const COMMON_LABELS = 'Common labels';
export const LogLevelColor = {
[LogLevel.critical]: colors[7],
[LogLevel.warning]: colors[1],
[LogLevel.error]: colors[4],
[LogLevel.warning]: colors[1],
[LogLevel.info]: colors[0],
[LogLevel.debug]: colors[5],
[LogLevel.trace]: colors[2],
+18 -12
View File
@@ -203,19 +203,8 @@ export const mergeLogsVolumeDataFrames = (dataFrames: DataFrame[]): { dataFrames
// collect and aggregate into aggregated object
dataFrames.forEach((dataFrame) => {
const fieldCache = new FieldCache(dataFrame);
const timeField = fieldCache.getFirstFieldOfType(FieldType.time);
const valueField = fieldCache.getFirstFieldOfType(FieldType.number);
const { level, valueField, timeField, length } = getLogLevelInfo(dataFrame);
if (!timeField) {
throw new Error('Missing time field');
}
if (!valueField) {
throw new Error('Missing value field');
}
const level = valueField.config.displayNameFromDS || dataFrame.name || 'logs';
const length = valueField.values.length;
configs[level] = {
meta: dataFrame.meta,
valueFieldConfig: valueField.config,
@@ -294,6 +283,23 @@ export const copyText = async (text: string, buttonRef: React.MutableRefObject<E
}
};
export function getLogLevelInfo(dataFrame: DataFrame) {
const fieldCache = new FieldCache(dataFrame);
const timeField = fieldCache.getFirstFieldOfType(FieldType.time);
const valueField = fieldCache.getFirstFieldOfType(FieldType.number);
if (!timeField) {
throw new Error('Missing time field');
}
if (!valueField) {
throw new Error('Missing value field');
}
const level = valueField.config.displayNameFromDS || dataFrame.name || 'logs';
const length = valueField.values.length;
return { level, valueField, timeField, length };
}
export function targetIsElement(target: EventTarget | null): target is Element {
return target instanceof Element;
}