Explore: Logs pinning in content outline (#88316)

* wip: working version

* add delete buttons, put pinned logs on top,

* Use already available onPinLine prop

* cleanup

* Fix alignment of pinned log

* Limit to 3 pinned log lines

* Format tooltip message

* Lower to font size and adjust padding so pinned log title is fully visible

* Add internationalization support in Explore Logs

* Update json for i18n

* Test remove button

* Open content outline after pinning a log

* Remove console.log statements

* Minor changes

* Conflict stuff
pull/89065/head
Haris Rozajac 1 year ago committed by GitHub
parent 667fea6623
commit 2d370f3983
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      .betterer.results
  2. 17
      public/app/features/explore/ContentOutline/ContentOutline.test.tsx
  3. 19
      public/app/features/explore/ContentOutline/ContentOutline.tsx
  4. 45
      public/app/features/explore/ContentOutline/ContentOutlineContext.tsx
  5. 7
      public/app/features/explore/ContentOutline/ContentOutlineItem.tsx
  6. 30
      public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx
  7. 3
      public/app/features/explore/Explore.tsx
  8. 65
      public/app/features/explore/Logs/Logs.tsx
  9. 3
      public/app/features/explore/Logs/LogsContainer.tsx
  10. 4
      public/app/features/logs/components/LogRow.tsx
  11. 7
      public/app/features/logs/components/LogRowMenuCell.tsx
  12. 4
      public/app/features/logs/components/LogRowMessage.tsx
  13. 5
      public/app/features/logs/components/LogRows.tsx
  14. 18
      public/app/plugins/panel/logs/panelcfg.cue
  15. 6
      public/locales/en-US/grafana.json
  16. 6
      public/locales/pseudo-LOCALE/grafana.json

@ -3891,9 +3891,7 @@ exports[`better eslint`] = {
], ],
"public/app/features/explore/Logs/Logs.tsx:5381": [ "public/app/features/explore/Logs/Logs.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
], ],
"public/app/features/explore/Logs/LogsFeedback.tsx:5381": [ "public/app/features/explore/Logs/LogsFeedback.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"] [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]

@ -11,6 +11,8 @@ jest.mock('./ContentOutlineContext', () => ({
const scrollIntoViewMock = jest.fn(); const scrollIntoViewMock = jest.fn();
const scrollerMock = document.createElement('div'); const scrollerMock = document.createElement('div');
const unregisterMock = jest.fn();
const setup = (mergeSingleChild = false) => { const setup = (mergeSingleChild = false) => {
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
@ -50,6 +52,7 @@ const setup = (mergeSingleChild = false) => {
title: 'Item 2-1', title: 'Item 2-1',
ref: document.createElement('div'), ref: document.createElement('div'),
level: 'child', level: 'child',
onRemove: () => unregisterMock('item-2-1'),
}, },
{ {
id: 'item-2-2', id: 'item-2-2',
@ -62,7 +65,7 @@ const setup = (mergeSingleChild = false) => {
}, },
], ],
register: jest.fn(), register: jest.fn(),
unregister: jest.fn(), unregister: unregisterMock,
}); });
return render(<ContentOutline scroller={scrollerMock} panelId="content-outline-container-1" />); return render(<ContentOutline scroller={scrollerMock} panelId="content-outline-container-1" />);
@ -143,4 +146,16 @@ describe('<ContentOutline />', () => {
await userEvent.click(button); await userEvent.click(button);
expect(button.getAttribute('aria-controls')).toBe(sectionContent.id); expect(button.getAttribute('aria-controls')).toBe(sectionContent.id);
}); });
it('deletes item on delete button click', async () => {
setup();
const expandSectionChevrons = screen.getAllByRole('button', { name: 'Content outline item collapse button' });
// chevron for the second item
const button = expandSectionChevrons[1];
await userEvent.click(button);
const deleteButtons = screen.getAllByTestId('content-outline-item-delete-button');
await userEvent.click(deleteButtons[0]);
expect(unregisterMock).toHaveBeenCalledWith('item-2-1');
});
}); });

@ -47,9 +47,11 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
(item) => item.children && !(item.mergeSingleChild && item.children?.length === 1) && item.children.length > 0 (item) => item.children && !(item.mergeSingleChild && item.children?.length === 1) && item.children.length > 0
); );
const outlineItemsHaveDeleteButton = outlineItems.some((item) => item.children?.some((child) => child.onRemove));
const [sectionsExpanded, setSectionsExpanded] = useState(() => { const [sectionsExpanded, setSectionsExpanded] = useState(() => {
return outlineItems.reduce((acc: { [key: string]: boolean }, item) => { return outlineItems.reduce((acc: { [key: string]: boolean }, item) => {
acc[item.id] = false; acc[item.id] = !!item.expanded;
return acc; return acc;
}, {}); }, {});
}); });
@ -58,6 +60,10 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
let scrollValue = 0; let scrollValue = 0;
let el: HTMLElement | null | undefined = ref; let el: HTMLElement | null | undefined = ref;
if (!el) {
return;
}
do { do {
scrollValue += el?.offsetTop || 0; scrollValue += el?.offsetTop || 0;
el = el?.offsetParent instanceof HTMLElement ? el.offsetParent : undefined; el = el?.offsetParent instanceof HTMLElement ? el.offsetParent : undefined;
@ -158,7 +164,7 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
title={contentOutlineExpanded ? item.title : undefined} title={contentOutlineExpanded ? item.title : undefined}
contentOutlineExpanded={contentOutlineExpanded} contentOutlineExpanded={contentOutlineExpanded}
className={cx(styles.buttonStyles, { className={cx(styles.buttonStyles, {
[styles.justifyCenter]: !contentOutlineExpanded, [styles.justifyCenter]: !contentOutlineExpanded && !outlineItemsHaveDeleteButton,
[styles.sectionHighlighter]: isChildActive(item, activeSectionChildId) && !contentOutlineExpanded, [styles.sectionHighlighter]: isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
})} })}
indentStyle={cx({ indentStyle={cx({
@ -196,7 +202,7 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
contentOutlineExpanded={contentOutlineExpanded} contentOutlineExpanded={contentOutlineExpanded}
icon={contentOutlineExpanded ? undefined : item.icon} icon={contentOutlineExpanded ? undefined : item.icon}
className={cx(styles.buttonStyles, { className={cx(styles.buttonStyles, {
[styles.justifyCenter]: !contentOutlineExpanded, [styles.justifyCenter]: !contentOutlineExpanded && !outlineItemsHaveDeleteButton,
[styles.sectionHighlighter]: [styles.sectionHighlighter]:
isChildActive(item, activeSectionChildId) && !contentOutlineExpanded, isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
})} })}
@ -211,6 +217,7 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
isActive={shouldBeActive(child, activeSectionId, activeSectionChildId, sectionsExpanded)} isActive={shouldBeActive(child, activeSectionId, activeSectionChildId, sectionsExpanded)}
extraHighlight={child.highlight} extraHighlight={child.highlight}
color={child.color} color={child.color}
onRemove={child.onRemove ? () => child.onRemove?.(child.id) : undefined}
/> />
</div> </div>
))} ))}
@ -257,10 +264,10 @@ const getStyles = (theme: GrafanaTheme2, expanded: boolean) => {
marginRight: expanded ? theme.spacing(0.5) : undefined, marginRight: expanded ? theme.spacing(0.5) : undefined,
}), }),
indentRoot: css({ indentRoot: css({
paddingLeft: theme.spacing(4), paddingLeft: theme.spacing(3),
}), }),
indentChild: css({ indentChild: css({
paddingLeft: expanded ? theme.spacing(7) : theme.spacing(4), paddingLeft: expanded ? theme.spacing(5) : theme.spacing(2.75),
}), }),
itemWrapper: css({ itemWrapper: css({
display: 'flex', display: 'flex',
@ -275,7 +282,7 @@ const getStyles = (theme: GrafanaTheme2, expanded: boolean) => {
borderRight: `1px solid ${theme.colors.border.medium}`, borderRight: `1px solid ${theme.colors.border.medium}`,
content: '""', content: '""',
height: '100%', height: '100%',
left: 48, left: theme.spacing(4.75),
position: 'absolute', position: 'absolute',
transform: 'translateX(50%)', transform: 'translateX(50%)',
}, },

@ -18,6 +18,7 @@ export interface ContentOutlineContextProps {
unregister: (id: string) => void; unregister: (id: string) => void;
unregisterAllChildren: (parentId: string, childType: ITEM_TYPES) => void; unregisterAllChildren: (parentId: string, childType: ITEM_TYPES) => void;
updateOutlineItems: (newItems: ContentOutlineItemContextProps[]) => void; updateOutlineItems: (newItems: ContentOutlineItemContextProps[]) => void;
updateItem: (id: string, properties: Partial<Omit<ContentOutlineItemContextProps, 'id'>>) => void;
} }
interface ContentOutlineContextProviderProps { interface ContentOutlineContextProviderProps {
@ -141,8 +142,11 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }:
ref = parent.ref; ref = parent.ref;
} }
const childrenUpdated = [...(parent.children || []), { ...outlineItem, id, ref }]; let childrenUpdated = [{ ...outlineItem, id, ref }, ...(parent.children || [])];
childrenUpdated.sort(sortElementsByDocumentPosition);
if (!outlineItem.childOnTop) {
childrenUpdated = sortItems(childrenUpdated);
}
newItems[parentIndex] = { newItems[parentIndex] = {
...parent, ...parent,
@ -175,6 +179,20 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }:
setOutlineItems(newItems); setOutlineItems(newItems);
}, []); }, []);
const updateItem = useCallback((id: string, properties: Partial<Omit<ContentOutlineItemContextProps, 'id'>>) => {
setOutlineItems((prevItems) =>
prevItems.map((item) => {
if (item.id === id) {
return {
...item,
...properties,
};
}
return item;
})
);
}, []);
const unregisterAllChildren = useCallback((parentId: string, childType: ITEM_TYPES) => { const unregisterAllChildren = useCallback((parentId: string, childType: ITEM_TYPES) => {
setOutlineItems((prevItems) => setOutlineItems((prevItems) =>
prevItems.map((item) => { prevItems.map((item) => {
@ -190,7 +208,8 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }:
setOutlineItems((prevItems) => { setOutlineItems((prevItems) => {
const newItems = [...prevItems]; const newItems = [...prevItems];
for (const item of newItems) { for (const item of newItems) {
item.children?.sort(sortElementsByDocumentPosition); const sortedItems = sortItems(item.children || []);
item.children = sortedItems;
} }
return newItems; return newItems;
}); });
@ -198,14 +217,14 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }:
return ( return (
<ContentOutlineContext.Provider <ContentOutlineContext.Provider
value={{ outlineItems, register, unregister, updateOutlineItems, unregisterAllChildren }} value={{ outlineItems, register, unregister, updateOutlineItems, unregisterAllChildren, updateItem }}
> >
{children} {children}
</ContentOutlineContext.Provider> </ContentOutlineContext.Provider>
); );
} }
export function sortElementsByDocumentPosition(a: ContentOutlineItemContextProps, b: ContentOutlineItemContextProps) { function sortElementsByDocumentPosition(a: ContentOutlineItemContextProps, b: ContentOutlineItemContextProps) {
if (a.ref && b.ref) { if (a.ref && b.ref) {
const diff = a.ref.compareDocumentPosition(b.ref); const diff = a.ref.compareDocumentPosition(b.ref);
if (diff === Node.DOCUMENT_POSITION_PRECEDING) { if (diff === Node.DOCUMENT_POSITION_PRECEDING) {
@ -217,6 +236,22 @@ export function sortElementsByDocumentPosition(a: ContentOutlineItemContextProps
return 0; return 0;
} }
function sortItems(outlineItems: ContentOutlineItemContextProps[]): ContentOutlineItemContextProps[] {
const [skipSort, sortable] = outlineItems.reduce<
[ContentOutlineItemContextProps[], ContentOutlineItemContextProps[]]
>(
(acc, item) => {
item.childOnTop ? acc[0].push(item) : acc[1].push(item);
return acc;
},
[[], []]
);
sortable.sort(sortElementsByDocumentPosition);
return [...skipSort, ...sortable];
}
export function useContentOutlineContext() { export function useContentOutlineContext() {
return useContext(ContentOutlineContext); return useContext(ContentOutlineContext);
} }

@ -35,6 +35,13 @@ export interface ContentOutlineItemBaseProps {
* Client can additionally mark filter actions as highlighted * Client can additionally mark filter actions as highlighted
*/ */
highlight?: boolean; highlight?: boolean;
onRemove?: (id: string) => void;
/**
* Child that will always be on top of the list
* e.g. pinned log in Logs section
*/
childOnTop?: boolean;
expanded?: boolean;
} }
interface ContentOutlineItemProps extends ContentOutlineItemBaseProps { interface ContentOutlineItemProps extends ContentOutlineItemBaseProps {

@ -2,7 +2,7 @@ import { cx, css } from '@emotion/css';
import React, { ButtonHTMLAttributes, useEffect, useRef, useState } from 'react'; import React, { ButtonHTMLAttributes, useEffect, useRef, useState } from 'react';
import { IconName, isIconName, GrafanaTheme2 } from '@grafana/data'; import { IconName, isIconName, GrafanaTheme2 } from '@grafana/data';
import { Icon, Tooltip, useTheme2 } from '@grafana/ui'; import { Button, Icon, Tooltip, useTheme2 } from '@grafana/ui';
import { TooltipPlacement } from '@grafana/ui/src/components/Tooltip'; import { TooltipPlacement } from '@grafana/ui/src/components/Tooltip';
type CommonProps = { type CommonProps = {
@ -20,6 +20,7 @@ type CommonProps = {
sectionId?: string; sectionId?: string;
toggleCollapsed?: () => void; toggleCollapsed?: () => void;
color?: string; color?: string;
onRemove?: () => void;
}; };
export type ContentOutlineItemButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>; export type ContentOutlineItemButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
@ -39,6 +40,7 @@ export function ContentOutlineItemButton({
sectionId, sectionId,
toggleCollapsed, toggleCollapsed,
color, color,
onRemove,
...rest ...rest
}: ContentOutlineItemButtonProps) { }: ContentOutlineItemButtonProps) {
const theme = useTheme2(); const theme = useTheme2();
@ -83,6 +85,15 @@ export function ContentOutlineItemButton({
</span> </span>
)} )}
</button> </button>
{onRemove && (
<Button
variant="destructive"
className={styles.deleteButton}
icon="times"
onClick={() => onRemove()}
data-testid="content-outline-item-delete-button"
/>
)}
</div> </div>
); );
@ -117,20 +128,20 @@ const getStyles = (theme: GrafanaTheme2, color?: string) => {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
flexGrow: 1, flexGrow: 1,
gap: theme.spacing(1), gap: theme.spacing(0.25),
overflow: 'hidden',
width: '100%', width: '100%',
overflow: 'hidden',
}), }),
button: css({ button: css({
label: 'content-outline-item-button', label: 'content-outline-item-button',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
height: theme.spacing(theme.components.height.md), height: theme.spacing(theme.components.height.md),
padding: theme.spacing(0, 1), gap: theme.spacing(0.5),
gap: theme.spacing(1),
color: theme.colors.text.secondary, color: theme.colors.text.secondary,
width: '100%', width: '100%',
background: 'transparent', background: 'transparent',
overflow: 'hidden',
border: 'none', border: 'none',
}), }),
collapseButton: css({ collapseButton: css({
@ -143,6 +154,7 @@ const getStyles = (theme: GrafanaTheme2, color?: string) => {
color: theme.colors.text.secondary, color: theme.colors.text.secondary,
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
overflow: 'hidden',
'&:hover': { '&:hover': {
color: theme.colors.text.primary, color: theme.colors.text.primary,
@ -153,6 +165,8 @@ const getStyles = (theme: GrafanaTheme2, color?: string) => {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
fontSize: theme.typography.bodySmall.fontSize,
marginLeft: theme.spacing(0.5),
}), }),
active: css({ active: css({
backgroundColor: theme.colors.background.secondary, backgroundColor: theme.colors.background.secondary,
@ -193,5 +207,11 @@ const getStyles = (theme: GrafanaTheme2, color?: string) => {
left: '2px', left: '2px',
}, },
}), }),
deleteButton: css({
width: theme.spacing(1),
height: theme.spacing(1),
padding: theme.spacing(0.75, 0.75),
marginRight: theme.spacing(0.5),
}),
}; };
}; };

@ -438,6 +438,9 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
isFilterLabelActive={this.isFilterLabelActive} isFilterLabelActive={this.isFilterLabelActive}
onClickFilterString={this.onClickFilterString} onClickFilterString={this.onClickFilterString}
onClickFilterOutString={this.onClickFilterOutString} onClickFilterOutString={this.onClickFilterOutString}
onPinLineCallback={() => {
this.setState({ contentOutlineVisible: true });
}}
/> />
</ContentOutlineItem> </ContentOutlineItem>
); );

@ -39,12 +39,14 @@ import {
InlineFieldRow, InlineFieldRow,
InlineSwitch, InlineSwitch,
PanelChrome, PanelChrome,
PopoverContent,
RadioButtonGroup, RadioButtonGroup,
SeriesVisibilityChangeMode, SeriesVisibilityChangeMode,
Themeable2, Themeable2,
withTheme2, withTheme2,
} from '@grafana/ui'; } from '@grafana/ui';
import { mapMouseEventToMode } from '@grafana/ui/src/components/VizLegend/utils'; import { mapMouseEventToMode } from '@grafana/ui/src/components/VizLegend/utils';
import { Trans } from 'app/core/internationalization';
import store from 'app/core/store'; import store from 'app/core/store';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll'; import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
@ -112,6 +114,7 @@ interface Props extends Themeable2 {
onClickFilterString?: (value: string, refId?: string) => void; onClickFilterString?: (value: string, refId?: string) => void;
onClickFilterOutString?: (value: string, refId?: string) => void; onClickFilterOutString?: (value: string, refId?: string) => void;
loadMoreLogs?(range: AbsoluteTimeRange): void; loadMoreLogs?(range: AbsoluteTimeRange): void;
onPinLineCallback?: () => void;
} }
export type LogsVisualisationType = 'table' | 'logs'; export type LogsVisualisationType = 'table' | 'logs';
@ -132,6 +135,7 @@ interface State {
tableFrame?: DataFrame; tableFrame?: DataFrame;
visualisationType?: LogsVisualisationType; visualisationType?: LogsVisualisationType;
logsContainer?: HTMLDivElement; logsContainer?: HTMLDivElement;
pinLineButtonTooltipTitle?: PopoverContent;
} }
// we need to define the order of these explicitly // we need to define the order of these explicitly
@ -156,6 +160,8 @@ const getDefaultVisualisationType = (): LogsVisualisationType => {
return 'logs'; return 'logs';
}; };
const PINNED_LOGS_LIMIT = 3;
class UnthemedLogs extends PureComponent<Props, State> { class UnthemedLogs extends PureComponent<Props, State> {
flipOrderTimer?: number; flipOrderTimer?: number;
cancelFlippingTimer?: number; cancelFlippingTimer?: number;
@ -183,6 +189,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
tableFrame: undefined, tableFrame: undefined,
visualisationType: this.props.panelState?.logs?.visualisationType ?? getDefaultVisualisationType(), visualisationType: this.props.panelState?.logs?.visualisationType ?? getDefaultVisualisationType(),
logsContainer: undefined, logsContainer: undefined,
pinLineButtonTooltipTitle: 'Pin to content outline',
}; };
constructor(props: Props) { constructor(props: Props) {
@ -652,6 +659,56 @@ class UnthemedLogs extends PureComponent<Props, State> {
this.topLogsRef.current?.scrollIntoView(); this.topLogsRef.current?.scrollIntoView();
}; };
onPinToContentOutlineClick = (row: LogRowModel) => {
if (this.getPinnedLogsCount() === PINNED_LOGS_LIMIT) {
this.setState({
pinLineButtonTooltipTitle: (
<span style={{ display: 'flex', textAlign: 'center' }}>
<Trans i18nKey="explore.logs.maximum-pinned-logs">
Maximum of {{ PINNED_LOGS_LIMIT }} pinned logs reached. Unpin a log to add another.
</Trans>
</span>
),
});
return;
}
// find the Logs parent item
const logsParent = this.context?.outlineItems.find((item) => item.panelId === 'Logs' && item.level === 'root');
//update the parent's expanded state
if (logsParent) {
this.context?.updateItem(logsParent.id, { expanded: true });
}
this.context?.register({
icon: 'gf-logs',
title: 'Pinned log',
panelId: 'Logs',
level: 'child',
ref: null,
color: LogLevelColor[row.logLevel],
childOnTop: true,
onClick: () => this.onOpenContext(row, () => {}),
onRemove: (id: string) => {
this.context?.unregister(id);
if (this.getPinnedLogsCount() < PINNED_LOGS_LIMIT) {
this.setState({
pinLineButtonTooltipTitle: 'Pin to content outline',
});
}
},
});
this.props.onPinLineCallback?.();
};
getPinnedLogsCount = () => {
const logsParent = this.context?.outlineItems.find((item) => item.panelId === 'Logs' && item.level === 'root');
return logsParent?.children?.filter((child) => child.title === 'Pinned log').length ?? 0;
};
render() { render() {
const { const {
width, width,
@ -945,6 +1002,8 @@ class UnthemedLogs extends PureComponent<Props, State> {
containerRendered={!!this.state.logsContainer} containerRendered={!!this.state.logsContainer}
onClickFilterString={this.props.onClickFilterString} onClickFilterString={this.props.onClickFilterString}
onClickFilterOutString={this.props.onClickFilterOutString} onClickFilterOutString={this.props.onClickFilterOutString}
onPinLine={this.onPinToContentOutlineClick}
pinLineButtonTooltipTitle={this.state.pinLineButtonTooltipTitle}
/> />
</InfiniteScroll> </InfiniteScroll>
</div> </div>
@ -952,9 +1011,9 @@ class UnthemedLogs extends PureComponent<Props, State> {
{!loading && !hasData && !scanning && ( {!loading && !hasData && !scanning && (
<div className={styles.logRows}> <div className={styles.logRows}>
<div className={styles.noData}> <div className={styles.noData}>
No logs found. <Trans i18nKey="explore.logs.no-logs-found">No logs found.</Trans>
<Button size="sm" variant="secondary" onClick={this.onClickScan}> <Button size="sm" variant="secondary" onClick={this.onClickScan}>
Scan for older logs <Trans i18nKey="explore.logs.scan-for-older-logs">Scan for older logs</Trans>
</Button> </Button>
</div> </div>
</div> </div>
@ -964,7 +1023,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
<div className={styles.noData}> <div className={styles.noData}>
<span>{scanText}</span> <span>{scanText}</span>
<Button size="sm" variant="secondary" onClick={this.onClickStopScan}> <Button size="sm" variant="secondary" onClick={this.onClickStopScan}>
Stop scan <Trans i18nKey="explore.logs.stop-scan">Stop scan</Trans>
</Button> </Button>
</div> </div>
</div> </div>

@ -60,6 +60,7 @@ interface LogsContainerProps extends PropsFromRedux {
isFilterLabelActive: (key: string, value: string, refId?: string) => Promise<boolean>; isFilterLabelActive: (key: string, value: string, refId?: string) => Promise<boolean>;
onClickFilterString: (value: string, refId?: string) => void; onClickFilterString: (value: string, refId?: string) => void;
onClickFilterOutString: (value: string, refId?: string) => void; onClickFilterOutString: (value: string, refId?: string) => void;
onPinLineCallback?: () => void;
} }
type DataSourceInstance = type DataSourceInstance =
@ -282,6 +283,7 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
exploreId, exploreId,
logsVolume, logsVolume,
scrollElement, scrollElement,
onPinLineCallback,
} = this.props; } = this.props;
if (!logRows) { if (!logRows) {
@ -350,6 +352,7 @@ class LogsContainer extends PureComponent<LogsContainerProps, LogsContainerState
scrollElement={scrollElement} scrollElement={scrollElement}
isFilterLabelActive={this.logDetailsFilterAvailable() ? this.props.isFilterLabelActive : undefined} isFilterLabelActive={this.logDetailsFilterAvailable() ? this.props.isFilterLabelActive : undefined}
range={range} range={range}
onPinLineCallback={onPinLineCallback}
onClickFilterString={this.filterValueAvailable() ? this.props.onClickFilterString : undefined} onClickFilterString={this.filterValueAvailable() ? this.props.onClickFilterString : undefined}
onClickFilterOutString={this.filterOutValueAvailable() ? this.props.onClickFilterOutString : undefined} onClickFilterOutString={this.filterOutValueAvailable() ? this.props.onClickFilterOutString : undefined}
/> />

@ -15,7 +15,7 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema'; import { DataQuery, TimeZone } from '@grafana/schema';
import { withTheme2, Themeable2, Icon, Tooltip } from '@grafana/ui'; import { withTheme2, Themeable2, Icon, Tooltip, PopoverContent } from '@grafana/ui';
import { checkLogsError, escapeUnescapedString } from '../utils'; import { checkLogsError, escapeUnescapedString } from '../utils';
@ -60,6 +60,7 @@ interface Props extends Themeable2 {
isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>; isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
onPinLine?: (row: LogRowModel) => void; onPinLine?: (row: LogRowModel) => void;
onUnpinLine?: (row: LogRowModel) => void; onUnpinLine?: (row: LogRowModel) => void;
pinLineButtonTooltipTitle?: PopoverContent;
pinned?: boolean; pinned?: boolean;
containerRendered?: boolean; containerRendered?: boolean;
handleTextSelection?: (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel) => boolean; handleTextSelection?: (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel) => boolean;
@ -305,6 +306,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
styles={styles} styles={styles}
onPinLine={this.props.onPinLine} onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine} onUnpinLine={this.props.onUnpinLine}
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
pinned={this.props.pinned} pinned={this.props.pinned}
mouseIsOver={this.state.mouseIsOver} mouseIsOver={this.state.mouseIsOver}
onBlur={this.onMouseLeave} onBlur={this.onMouseLeave}

@ -2,7 +2,7 @@ import React, { FocusEvent, SyntheticEvent, useCallback } from 'react';
import { LogRowContextOptions, LogRowModel, getDefaultTimeRange, locationUtil, urlUtil } from '@grafana/data'; import { LogRowContextOptions, LogRowModel, getDefaultTimeRange, locationUtil, urlUtil } from '@grafana/data';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { ClipboardButton, IconButton } from '@grafana/ui'; import { ClipboardButton, IconButton, PopoverContent } from '@grafana/ui';
import { getConfig } from 'app/core/config'; import { getConfig } from 'app/core/config';
import { LogRowStyles } from './getLogRowStyles'; import { LogRowStyles } from './getLogRowStyles';
@ -20,10 +20,12 @@ interface Props {
onPermalinkClick?: (row: LogRowModel) => Promise<void>; onPermalinkClick?: (row: LogRowModel) => Promise<void>;
onPinLine?: (row: LogRowModel) => void; onPinLine?: (row: LogRowModel) => void;
onUnpinLine?: (row: LogRowModel) => void; onUnpinLine?: (row: LogRowModel) => void;
pinLineButtonTooltipTitle?: PopoverContent;
pinned?: boolean; pinned?: boolean;
styles: LogRowStyles; styles: LogRowStyles;
mouseIsOver: boolean; mouseIsOver: boolean;
onBlur: () => void; onBlur: () => void;
onPinToContentOutlineClick?: (row: LogRowModel, onOpenContext: (row: LogRowModel) => void) => void;
} }
export const LogRowMenuCell = React.memo( export const LogRowMenuCell = React.memo(
@ -33,6 +35,7 @@ export const LogRowMenuCell = React.memo(
onPermalinkClick, onPermalinkClick,
onPinLine, onPinLine,
onUnpinLine, onUnpinLine,
pinLineButtonTooltipTitle,
pinned, pinned,
row, row,
showContextToggle, showContextToggle,
@ -145,7 +148,7 @@ export const LogRowMenuCell = React.memo(
size="md" size="md"
name="gf-pin" name="gf-pin"
onClick={() => onPinLine && onPinLine(row)} onClick={() => onPinLine && onPinLine(row)}
tooltip="Pin line" tooltip={pinLineButtonTooltipTitle ?? 'Pin line'}
tooltipPlacement="top" tooltipPlacement="top"
aria-label="Pin line" aria-label="Pin line"
tabIndex={0} tabIndex={0}

@ -3,6 +3,7 @@ import Highlighter from 'react-highlight-words';
import { CoreApp, findHighlightChunksInText, LogRowContextOptions, LogRowModel } from '@grafana/data'; import { CoreApp, findHighlightChunksInText, LogRowContextOptions, LogRowModel } from '@grafana/data';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { PopoverContent } from '@grafana/ui';
import { LogMessageAnsi } from './LogMessageAnsi'; import { LogMessageAnsi } from './LogMessageAnsi';
import { LogRowMenuCell } from './LogRowMenuCell'; import { LogRowMenuCell } from './LogRowMenuCell';
@ -25,6 +26,7 @@ interface Props {
onPermalinkClick?: (row: LogRowModel) => Promise<void>; onPermalinkClick?: (row: LogRowModel) => Promise<void>;
onPinLine?: (row: LogRowModel) => void; onPinLine?: (row: LogRowModel) => void;
onUnpinLine?: (row: LogRowModel) => void; onUnpinLine?: (row: LogRowModel) => void;
pinLineButtonTooltipTitle?: PopoverContent;
pinned?: boolean; pinned?: boolean;
styles: LogRowStyles; styles: LogRowStyles;
mouseIsOver: boolean; mouseIsOver: boolean;
@ -88,6 +90,7 @@ export const LogRowMessage = React.memo((props: Props) => {
onPermalinkClick, onPermalinkClick,
onUnpinLine, onUnpinLine,
onPinLine, onPinLine,
pinLineButtonTooltipTitle,
pinned, pinned,
mouseIsOver, mouseIsOver,
onBlur, onBlur,
@ -124,6 +127,7 @@ export const LogRowMessage = React.memo((props: Props) => {
onPermalinkClick={onPermalinkClick} onPermalinkClick={onPermalinkClick}
onPinLine={onPinLine} onPinLine={onPinLine}
onUnpinLine={onUnpinLine} onUnpinLine={onUnpinLine}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
pinned={pinned} pinned={pinned}
styles={styles} styles={styles}
mouseIsOver={mouseIsOver} mouseIsOver={mouseIsOver}

@ -15,7 +15,7 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { withTheme2, Themeable2 } from '@grafana/ui'; import { withTheme2, Themeable2, PopoverContent } from '@grafana/ui';
import { PopoverMenu } from '../../explore/Logs/PopoverMenu'; import { PopoverMenu } from '../../explore/Logs/PopoverMenu';
import { UniqueKeyMaker } from '../UniqueKeyMaker'; import { UniqueKeyMaker } from '../UniqueKeyMaker';
@ -50,6 +50,7 @@ export interface Props extends Themeable2 {
onClickHideField?: (key: string) => void; onClickHideField?: (key: string) => void;
onPinLine?: (row: LogRowModel) => void; onPinLine?: (row: LogRowModel) => void;
onUnpinLine?: (row: LogRowModel) => void; onUnpinLine?: (row: LogRowModel) => void;
pinLineButtonTooltipTitle?: PopoverContent;
onLogRowHover?: (row?: LogRowModel) => void; onLogRowHover?: (row?: LogRowModel) => void;
onOpenContext?: (row: LogRowModel, onClose: () => void) => void; onOpenContext?: (row: LogRowModel, onClose: () => void) => void;
getRowContextQuery?: ( getRowContextQuery?: (
@ -238,6 +239,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
permalinkedRowId={this.props.permalinkedRowId} permalinkedRowId={this.props.permalinkedRowId}
onPinLine={this.props.onPinLine} onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine} onUnpinLine={this.props.onUnpinLine}
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
pinned={this.props.pinnedRowId === row.uid} pinned={this.props.pinnedRowId === row.uid}
isFilterLabelActive={this.props.isFilterLabelActive} isFilterLabelActive={this.props.isFilterLabelActive}
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined} handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined}
@ -260,6 +262,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
permalinkedRowId={this.props.permalinkedRowId} permalinkedRowId={this.props.permalinkedRowId}
onPinLine={this.props.onPinLine} onPinLine={this.props.onPinLine}
onUnpinLine={this.props.onUnpinLine} onUnpinLine={this.props.onUnpinLine}
pinLineButtonTooltipTitle={this.props.pinLineButtonTooltipTitle}
pinned={this.props.pinnedRowId === row.uid} pinned={this.props.pinnedRowId === row.uid}
isFilterLabelActive={this.props.isFilterLabelActive} isFilterLabelActive={this.props.isFilterLabelActive}
handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined} handleTextSelection={this.popoverMenuSupported() ? this.handleSelection : undefined}

@ -26,15 +26,15 @@ composableKinds: PanelCfg: {
version: [0, 0] version: [0, 0]
schema: { schema: {
Options: { Options: {
showLabels: bool showLabels: bool
showCommonLabels: bool showCommonLabels: bool
showTime: bool showTime: bool
showLogContextToggle: bool showLogContextToggle: bool
wrapLogMessage: bool wrapLogMessage: bool
prettifyLogMessage: bool prettifyLogMessage: bool
enableLogDetails: bool enableLogDetails: bool
sortOrder: common.LogsSortOrder sortOrder: common.LogsSortOrder
dedupStrategy: common.LogsDedupStrategy dedupStrategy: common.LogsDedupStrategy
// TODO: figure out how to define callbacks // TODO: figure out how to define callbacks
onClickFilterLabel?: _ onClickFilterLabel?: _
onClickFilterOutLabel?: _ onClickFilterOutLabel?: _

@ -497,6 +497,12 @@
"title": "Add query to Query Library", "title": "Add query to Query Library",
"visibility": "Visibility" "visibility": "Visibility"
}, },
"logs": {
"maximum-pinned-logs": "Maximum of {{PINNED_LOGS_LIMIT}} pinned logs reached. Unpin a log to add another.",
"no-logs-found": "No logs found.",
"scan-for-older-logs": "Scan for older logs",
"stop-scan": "Stop scan"
},
"rich-history": { "rich-history": {
"close-tooltip": "Close query history", "close-tooltip": "Close query history",
"datasource-a-z": "Data source A-Z", "datasource-a-z": "Data source A-Z",

@ -497,6 +497,12 @@
"title": "Åđđ qūęřy ŧő Qūęřy Ŀįþřäřy", "title": "Åđđ qūęřy ŧő Qūęřy Ŀįþřäřy",
"visibility": "Vįşįþįľįŧy" "visibility": "Vįşįþįľįŧy"
}, },
"logs": {
"maximum-pinned-logs": "Mäχįmūm őƒ {{PINNED_LOGS_LIMIT}} pįʼnʼnęđ ľőģş řęäčĥęđ. Ůʼnpįʼn ä ľőģ ŧő äđđ äʼnőŧĥęř.",
"no-logs-found": "Ńő ľőģş ƒőūʼnđ.",
"scan-for-older-logs": "Ŝčäʼn ƒőř őľđęř ľőģş",
"stop-scan": "Ŝŧőp şčäʼn"
},
"rich-history": { "rich-history": {
"close-tooltip": "Cľőşę qūęřy ĥįşŧőřy", "close-tooltip": "Cľőşę qūęřy ĥįşŧőřy",
"datasource-a-z": "Đäŧä şőūřčę Å-Ż", "datasource-a-z": "Đäŧä şőūřčę Å-Ż",

Loading…
Cancel
Save