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:
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user