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
This commit is contained in:
Haris Rozajac 2024-06-11 11:15:36 -06:00 committed by GitHub
parent 667fea6623
commit 2d370f3983
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 207 additions and 36 deletions

View File

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

View File

@ -11,6 +11,8 @@ jest.mock('./ContentOutlineContext', () => ({
const scrollIntoViewMock = jest.fn();
const scrollerMock = document.createElement('div');
const unregisterMock = jest.fn();
const setup = (mergeSingleChild = false) => {
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
@ -50,6 +52,7 @@ const setup = (mergeSingleChild = false) => {
title: 'Item 2-1',
ref: document.createElement('div'),
level: 'child',
onRemove: () => unregisterMock('item-2-1'),
},
{
id: 'item-2-2',
@ -62,7 +65,7 @@ const setup = (mergeSingleChild = false) => {
},
],
register: jest.fn(),
unregister: jest.fn(),
unregister: unregisterMock,
});
return render(<ContentOutline scroller={scrollerMock} panelId="content-outline-container-1" />);
@ -143,4 +146,16 @@ describe('<ContentOutline />', () => {
await userEvent.click(button);
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');
});
});

View File

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

View File

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

View File

@ -35,6 +35,13 @@ export interface ContentOutlineItemBaseProps {
* Client can additionally mark filter actions as highlighted
*/
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 {

View File

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

View File

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

View File

@ -39,12 +39,14 @@ import {
InlineFieldRow,
InlineSwitch,
PanelChrome,
PopoverContent,
RadioButtonGroup,
SeriesVisibilityChangeMode,
Themeable2,
withTheme2,
} from '@grafana/ui';
import { mapMouseEventToMode } from '@grafana/ui/src/components/VizLegend/utils';
import { Trans } from 'app/core/internationalization';
import store from 'app/core/store';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
@ -112,6 +114,7 @@ interface Props extends Themeable2 {
onClickFilterString?: (value: string, refId?: string) => void;
onClickFilterOutString?: (value: string, refId?: string) => void;
loadMoreLogs?(range: AbsoluteTimeRange): void;
onPinLineCallback?: () => void;
}
export type LogsVisualisationType = 'table' | 'logs';
@ -132,6 +135,7 @@ interface State {
tableFrame?: DataFrame;
visualisationType?: LogsVisualisationType;
logsContainer?: HTMLDivElement;
pinLineButtonTooltipTitle?: PopoverContent;
}
// we need to define the order of these explicitly
@ -156,6 +160,8 @@ const getDefaultVisualisationType = (): LogsVisualisationType => {
return 'logs';
};
const PINNED_LOGS_LIMIT = 3;
class UnthemedLogs extends PureComponent<Props, State> {
flipOrderTimer?: number;
cancelFlippingTimer?: number;
@ -183,6 +189,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
tableFrame: undefined,
visualisationType: this.props.panelState?.logs?.visualisationType ?? getDefaultVisualisationType(),
logsContainer: undefined,
pinLineButtonTooltipTitle: 'Pin to content outline',
};
constructor(props: Props) {
@ -652,6 +659,56 @@ class UnthemedLogs extends PureComponent<Props, State> {
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() {
const {
width,
@ -945,6 +1002,8 @@ class UnthemedLogs extends PureComponent<Props, State> {
containerRendered={!!this.state.logsContainer}
onClickFilterString={this.props.onClickFilterString}
onClickFilterOutString={this.props.onClickFilterOutString}
onPinLine={this.onPinToContentOutlineClick}
pinLineButtonTooltipTitle={this.state.pinLineButtonTooltipTitle}
/>
</InfiniteScroll>
</div>
@ -952,9 +1011,9 @@ class UnthemedLogs extends PureComponent<Props, State> {
{!loading && !hasData && !scanning && (
<div className={styles.logRows}>
<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}>
Scan for older logs
<Trans i18nKey="explore.logs.scan-for-older-logs">Scan for older logs</Trans>
</Button>
</div>
</div>
@ -964,7 +1023,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
<div className={styles.noData}>
<span>{scanText}</span>
<Button size="sm" variant="secondary" onClick={this.onClickStopScan}>
Stop scan
<Trans i18nKey="explore.logs.stop-scan">Stop scan</Trans>
</Button>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -497,6 +497,12 @@
"title": "Add query to Query Library",
"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": {
"close-tooltip": "Close query history",
"datasource-a-z": "Data source A-Z",

View File

@ -497,6 +497,12 @@
"title": "Åđđ qūęřy ŧő Qūęřy Ŀįþřäř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": {
"close-tooltip": "Cľőşę qūęřy ĥįşŧőřy",
"datasource-a-z": "Đäŧä şőūřčę Å-Ż",