Fields Selector: Add component and integrate with Logs and Logs table visualization (#112534)
* Copy base components * Refactor FieldSelector and add LogList wrapper * Get field names from data frame * FieldSelector: get labels and fields from dataframes * Show active fields * Show all fields * Fix field list height * Toggle fields * Reorder fields * Hide active fields if there are no fields * Show suggested fields * Sort available fields * Hide drag icon if not draggable * Filter fields * Add collapse control * Table: use FieldSelector component * Remove deprecated components * Improve Logs Table sidebar * Store table sidebar resizes * Lint issues * Minor refactor to columns getters * Fix column sorting * Translations * Fix columns reset * Prettier * Add showFieldSelector option * Implement suggested fields * Remove margin from button * Update stored width on expand/collapse * Update test * Add unit test * Prettier * Translations * Add integration test * Return null * Update comment * More integration tests * Use forEach * Make dataFrame possibly undefined * Move components * Memoize callback * Prettier * Table: improve resize behavior * Update public/app/features/logs/components/fieldSelector/FieldSelector.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Prettier * Analytics: report sidebar state * Analytics: add interactions * Search: add clear button * Translations * LogLineContext: pass missing storage key prop 953¿ * Field: report toggling --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
parent
be1b18a3d6
commit
9e0fe94e8d
@@ -40,6 +40,7 @@ export interface Options {
|
||||
setDisplayedFields?: unknown;
|
||||
showCommonLabels: boolean;
|
||||
showControls?: boolean;
|
||||
showFieldSelector?: boolean;
|
||||
showLabels: boolean;
|
||||
showLogAttributes?: boolean;
|
||||
showLogContextToggle: boolean;
|
||||
|
||||
@@ -995,7 +995,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
panelState={panelState?.logs}
|
||||
theme={theme}
|
||||
updatePanelState={updatePanelState}
|
||||
datasourceType={props.datasourceType}
|
||||
/>
|
||||
@@ -1134,6 +1133,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
app={CoreApp.Explore}
|
||||
containerElement={logsContainerRef.current}
|
||||
enableLogDetails={true}
|
||||
dataFrames={props.logsFrames ?? []}
|
||||
dedupStrategy={dedupStrategy}
|
||||
displayedFields={displayedFields}
|
||||
filterLevels={filterLevels}
|
||||
@@ -1163,6 +1163,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
pinnedLogs={pinnedLogs}
|
||||
setDisplayedFields={setDisplayedFields}
|
||||
showControls
|
||||
showFieldSelector
|
||||
showTime={showTime}
|
||||
sortOrder={logsSortOrder}
|
||||
timeRange={props.range}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { DragDropContext, Draggable, DraggableProvided, Droppable, DropResult } from '@hello-pangea/dnd';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { LogsTableEmptyFields } from './LogsTableEmptyFields';
|
||||
import { LogsTableNavField } from './LogsTableNavField';
|
||||
import { FieldNameMeta } from './LogsTableWrap';
|
||||
|
||||
export function getLogsFieldsStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrap: css({
|
||||
marginTop: theme.spacing(1),
|
||||
marginBottom: theme.spacing(1),
|
||||
display: 'flex',
|
||||
background: theme.colors.background.primary,
|
||||
}),
|
||||
dragging: css({
|
||||
background: theme.colors.background.secondary,
|
||||
}),
|
||||
columnWrapper: css({
|
||||
marginBottom: theme.spacing(1.5),
|
||||
// need some space or the outline of the checkbox is cut off
|
||||
paddingLeft: theme.spacing(0.5),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function sortLabels(labels: Record<string, FieldNameMeta>) {
|
||||
return (a: string, b: string) => {
|
||||
const la = labels[a];
|
||||
const lb = labels[b];
|
||||
|
||||
// Sort by index
|
||||
if (la.index != null && lb.index != null) {
|
||||
return la.index - lb.index;
|
||||
}
|
||||
|
||||
// otherwise do not sort
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
export const LogsTableActiveFields = (props: {
|
||||
labels: Record<string, FieldNameMeta>;
|
||||
valueFilter: (value: string) => boolean;
|
||||
toggleColumn: (columnName: string) => void;
|
||||
reorderColumn: (sourceIndex: number, destinationIndex: number) => void;
|
||||
id: string;
|
||||
}): JSX.Element => {
|
||||
const { reorderColumn, labels, valueFilter, toggleColumn } = props;
|
||||
const theme = useTheme2();
|
||||
const styles = getLogsFieldsStyles(theme);
|
||||
const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName));
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
reorderColumn(result.source.index, result.destination.index);
|
||||
};
|
||||
|
||||
const renderTitle = (labelName: string) => {
|
||||
const label = labels[labelName];
|
||||
if (label) {
|
||||
return `${labelName} appears in ${label?.percentOfLinesWithLabel}% of log lines`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (labelKeys.length) {
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="order-fields" direction="vertical">
|
||||
{(provided) => (
|
||||
<div className={styles.columnWrapper} {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{labelKeys.sort(sortLabels(labels)).map((labelName, index) => (
|
||||
<Draggable draggableId={labelName} key={labelName} index={index}>
|
||||
{(provided: DraggableProvided, snapshot) => (
|
||||
<div
|
||||
className={cx(styles.wrap, snapshot.isDragging ? styles.dragging : undefined)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
title={renderTitle(labelName)}
|
||||
>
|
||||
<LogsTableNavField
|
||||
label={labelName}
|
||||
onChange={() => toggleColumn(labelName)}
|
||||
labels={labels}
|
||||
draggable={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
||||
|
||||
return <LogsTableEmptyFields />;
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import { t } from '@grafana/i18n';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from 'app/features/logs/components/otel/formats';
|
||||
|
||||
import { getLogsFieldsStyles } from './LogsTableActiveFields';
|
||||
import { LogsTableEmptyFields } from './LogsTableEmptyFields';
|
||||
import { LogsTableNavField } from './LogsTableNavField';
|
||||
import { FieldNameMeta } from './LogsTableWrap';
|
||||
|
||||
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
|
||||
|
||||
function sortLabels(labels: Record<string, FieldNameMeta>) {
|
||||
return (a: string, b: string) => {
|
||||
const la = labels[a];
|
||||
const lb = labels[b];
|
||||
|
||||
// ...sort by type and alphabetically
|
||||
if (la != null && lb != null) {
|
||||
return (
|
||||
Number(lb.type === 'TIME_FIELD') - Number(la.type === 'TIME_FIELD') ||
|
||||
Number(lb.type === 'BODY_FIELD') - Number(la.type === 'BODY_FIELD') ||
|
||||
collator.compare(a, b)
|
||||
);
|
||||
}
|
||||
|
||||
// otherwise do not sort
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
export const LogsTableAvailableFields = (props: {
|
||||
labels: Record<string, FieldNameMeta>;
|
||||
valueFilter: (value: string) => boolean;
|
||||
toggleColumn: (columnName: string) => void;
|
||||
}): JSX.Element => {
|
||||
const { labels, valueFilter, toggleColumn } = props;
|
||||
const theme = useTheme2();
|
||||
|
||||
const styles = getLogsFieldsStyles(theme);
|
||||
const labelKeys = Object.keys(labels)
|
||||
.filter((labelName) => labelName !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)
|
||||
.filter((labelName) => valueFilter(labelName));
|
||||
if (labelKeys.length) {
|
||||
// Otherwise show list with a hardcoded order
|
||||
return (
|
||||
<div className={styles.columnWrapper}>
|
||||
{labelKeys.sort(sortLabels(labels)).map((labelName, index) => (
|
||||
<div
|
||||
key={labelName}
|
||||
className={styles.wrap}
|
||||
title={t(
|
||||
'explore.logs-table-available-fields.title-label-percentage',
|
||||
'{{labelName}} appears in {{percentage}}% of log lines',
|
||||
{ labelName, percentage: labels[labelName]?.percentOfLinesWithLabel }
|
||||
)}
|
||||
>
|
||||
<LogsTableNavField
|
||||
showCount={true}
|
||||
label={labelName}
|
||||
onChange={() => toggleColumn(labelName)}
|
||||
labels={labels}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <LogsTableEmptyFields />;
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { LogsTableActiveFields } from './LogsTableActiveFields';
|
||||
import { LogsTableAvailableFields } from './LogsTableAvailableFields';
|
||||
import { FieldNameMeta } from './LogsTableWrap';
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
sidebarWrap: css({
|
||||
overflowY: 'scroll',
|
||||
height: 'calc(100% - 50px)',
|
||||
/* Hide scrollbar for Chrome, Safari, and Opera */
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
/* Hide scrollbar for Firefox */
|
||||
scrollbarWidth: 'none',
|
||||
}),
|
||||
columnHeaderButton: css({
|
||||
appearance: 'none',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: theme.typography.pxToRem(11),
|
||||
}),
|
||||
columnHeader: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: theme.typography.h6.fontSize,
|
||||
background: theme.colors.background.secondary,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
left: 0,
|
||||
paddingTop: theme.spacing(0.75),
|
||||
paddingRight: theme.spacing(0.75),
|
||||
paddingBottom: theme.spacing(0.75),
|
||||
paddingLeft: theme.spacing(1.5),
|
||||
zIndex: 3,
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const LogsTableMultiSelect = (props: {
|
||||
toggleColumn: (columnName: string) => void;
|
||||
filteredColumnsWithMeta: Record<string, FieldNameMeta> | undefined;
|
||||
columnsWithMeta: Record<string, FieldNameMeta>;
|
||||
clear: () => void;
|
||||
reorderColumn: (oldIndex: number, newIndex: number) => void;
|
||||
}) => {
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={styles.sidebarWrap}>
|
||||
{/* Sidebar columns */}
|
||||
<>
|
||||
<div className={styles.columnHeader}>
|
||||
<Trans i18nKey="explore.logs-table-multi-select.selected-fields">Selected fields</Trans>
|
||||
<button onClick={props.clear} className={styles.columnHeaderButton}>
|
||||
<Trans i18nKey="explore.logs-table-multi-select.reset">Reset</Trans>
|
||||
</button>
|
||||
</div>
|
||||
<LogsTableActiveFields
|
||||
reorderColumn={props.reorderColumn}
|
||||
toggleColumn={props.toggleColumn}
|
||||
labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta}
|
||||
valueFilter={(value) => props.columnsWithMeta[value]?.active ?? false}
|
||||
id={'selected-fields'}
|
||||
/>
|
||||
|
||||
<div className={styles.columnHeader}>
|
||||
<Trans i18nKey="explore.logs-table-multi-select.fields">Fields</Trans>
|
||||
</div>
|
||||
<LogsTableAvailableFields
|
||||
toggleColumn={props.toggleColumn}
|
||||
labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta}
|
||||
valueFilter={(value) => !props.columnsWithMeta[value]?.active}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Checkbox, Icon, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { FieldNameMeta } from './LogsTableWrap';
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
dragIcon: css({
|
||||
cursor: 'drag',
|
||||
marginLeft: theme.spacing(1),
|
||||
opacity: 0.4,
|
||||
}),
|
||||
labelCount: css({
|
||||
marginLeft: theme.spacing(0.5),
|
||||
marginRight: theme.spacing(0.5),
|
||||
appearance: 'none',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: theme.typography.pxToRem(11),
|
||||
opacity: 0.6,
|
||||
}),
|
||||
contentWrap: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
}),
|
||||
// Hide text that overflows, had to select elements within the Checkbox component, so this is a bit fragile
|
||||
checkboxLabel: css({
|
||||
'> span': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function LogsTableNavField(props: {
|
||||
label: string;
|
||||
onChange: () => void;
|
||||
labels: Record<string, FieldNameMeta>;
|
||||
draggable?: boolean;
|
||||
showCount?: boolean;
|
||||
}): React.JSX.Element | undefined {
|
||||
const theme = useTheme2();
|
||||
|
||||
const styles = getStyles(theme);
|
||||
|
||||
if (props.labels[props.label]) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.contentWrap}>
|
||||
<Checkbox
|
||||
className={styles.checkboxLabel}
|
||||
label={props.label}
|
||||
onChange={props.onChange}
|
||||
checked={props.labels[props.label]?.active ?? false}
|
||||
/>
|
||||
{props.showCount && (
|
||||
<button className={styles.labelCount} onClick={props.onChange}>
|
||||
{props.labels[props.label]?.percentOfLinesWithLabel}%
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{props.draggable && (
|
||||
<Icon
|
||||
aria-label={t('explore.logs-table-nav-field.aria-label-drag-and-drop-icon', 'Drag and drop icon')}
|
||||
title={t('explore.logs-table-nav-field.title-drag-and-drop-to-reorder', 'Drag and drop to reorder')}
|
||||
name="draggabledots"
|
||||
size="lg"
|
||||
className={styles.dragIcon}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import { createTheme, ExploreLogsPanelState, LogsSortOrder, toUtc } from '@grafana/data';
|
||||
import { ExploreLogsPanelState, LogsSortOrder, toUtc } from '@grafana/data';
|
||||
import { mockTransformationsRegistry, organizeFieldsTransformer } from '@grafana/data/internal';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
@@ -27,7 +27,6 @@ const getComponent = (partialProps?: Partial<ComponentProps<typeof LogsTableWrap
|
||||
timeZone={'utc'}
|
||||
width={50}
|
||||
logsFrames={[getMockLokiFrame()]}
|
||||
theme={createTheme()}
|
||||
{...partialProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Resizable, ResizeCallback } from 're-resizable';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
@@ -11,20 +10,25 @@ import {
|
||||
LogsSortOrder,
|
||||
SelectableValue,
|
||||
SplitOpen,
|
||||
store,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { InlineField, Select, Themeable2 } from '@grafana/ui';
|
||||
import { getDragStyles, InlineField, Select, useStyles2 } from '@grafana/ui';
|
||||
import {
|
||||
getSidebarWidth,
|
||||
LogsTableFieldSelector,
|
||||
MIN_WIDTH,
|
||||
} from 'app/features/logs/components/fieldSelector/FieldSelector';
|
||||
import { reportInteractionOnce } from 'app/features/logs/components/panel/analytics';
|
||||
|
||||
import { parseLogsFrame } from '../../logs/logsFrame';
|
||||
|
||||
import { LogsColumnSearch } from './LogsColumnSearch';
|
||||
import { LogsTable } from './LogsTable';
|
||||
import { LogsTableMultiSelect } from './LogsTableMultiSelect';
|
||||
import { fuzzySearch } from './utils/uFuzzy';
|
||||
import { SETTING_KEY_ROOT } from './utils/logs';
|
||||
|
||||
interface Props extends Themeable2 {
|
||||
interface Props {
|
||||
logsFrames: DataFrame[];
|
||||
width: number;
|
||||
timeZone: string;
|
||||
@@ -56,17 +60,17 @@ type GenericMeta = {
|
||||
export type FieldNameMeta = (InactiveFieldMeta | ActiveFieldMeta) & GenericMeta;
|
||||
|
||||
type FieldName = string;
|
||||
type FieldNameMetaStore = Record<FieldName, FieldNameMeta>;
|
||||
export type FieldNameMetaStore = Record<FieldName, FieldNameMeta>;
|
||||
|
||||
export function LogsTableWrap(props: Props) {
|
||||
const { logsFrames, updatePanelState, panelState } = props;
|
||||
const propsColumns = panelState?.columns;
|
||||
// Save the normalized cardinality of each label
|
||||
const [columnsWithMeta, setColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined);
|
||||
const dragStyles = useStyles2(getDragStyles);
|
||||
|
||||
// Filtered copy of columnsWithMeta that only includes matching results
|
||||
const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined);
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
|
||||
const height = getLogsTableHeight();
|
||||
const panelStateRefId = props?.panelState?.refId;
|
||||
@@ -91,7 +95,8 @@ export function LogsTableWrap(props: Props) {
|
||||
},
|
||||
[props.panelState?.columns]
|
||||
);
|
||||
const logsFrame = parseLogsFrame(currentDataFrame);
|
||||
|
||||
const logsFrame = useMemo(() => parseLogsFrame(currentDataFrame), [currentDataFrame]);
|
||||
|
||||
useEffect(() => {
|
||||
if (logsFrame?.timeField.name && logsFrame?.bodyField.name && !propsColumns) {
|
||||
@@ -268,9 +273,11 @@ export function LogsTableWrap(props: Props) {
|
||||
// The panel state is updated when the user interacts with the multi-select sidebar
|
||||
}, [currentDataFrame, getColumnsFromProps]);
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState(220);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(getSidebarWidth(SETTING_KEY_ROOT));
|
||||
const tableWidth = props.width - sidebarWidth;
|
||||
|
||||
const styles = useStyles2(getStyles, height, sidebarWidth);
|
||||
|
||||
if (!columnsWithMeta) {
|
||||
return null;
|
||||
}
|
||||
@@ -288,46 +295,27 @@ export function LogsTableWrap(props: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function searchFilterEvent(searchResultCount: number) {
|
||||
reportInteraction('grafana_explore_logs_table_text_search_result_count', {
|
||||
resultCount: searchResultCount,
|
||||
datasourceType: props.datasourceType ?? 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
const pendingLabelState = { ...columnsWithMeta };
|
||||
let index = 0;
|
||||
Object.keys(pendingLabelState).forEach((key) => {
|
||||
const isDefaultField = !!pendingLabelState[key].type;
|
||||
// after reset the only active fields are the special time and body fields
|
||||
pendingLabelState[key].active = isDefaultField;
|
||||
pendingLabelState[key].active = isDefaultField ? true : false;
|
||||
// reset the index
|
||||
pendingLabelState[key].index = isDefaultField ? index++ : undefined;
|
||||
if (pendingLabelState[key].type === 'TIME_FIELD') {
|
||||
pendingLabelState[key].index = 0;
|
||||
} else {
|
||||
pendingLabelState[key].index = pendingLabelState[key].type === 'BODY_FIELD' ? 1 : undefined;
|
||||
}
|
||||
});
|
||||
setColumnsWithMeta(pendingLabelState);
|
||||
};
|
||||
|
||||
const reorderColumn = (sourceIndex: number, destinationIndex: number) => {
|
||||
if (sourceIndex === destinationIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reorderColumn = (newColumns: string[]) => {
|
||||
const pendingLabelState = { ...columnsWithMeta };
|
||||
|
||||
const keys = Object.keys(pendingLabelState)
|
||||
.filter((key) => pendingLabelState[key].active)
|
||||
.map((key) => ({
|
||||
fieldName: key,
|
||||
index: pendingLabelState[key].index ?? 0,
|
||||
}))
|
||||
.sort((a, b) => a.index - b.index);
|
||||
|
||||
const [source] = keys.splice(sourceIndex, 1);
|
||||
keys.splice(destinationIndex, 0, source);
|
||||
|
||||
keys.forEach((key, index) => {
|
||||
pendingLabelState[key.fieldName].index = index;
|
||||
newColumns.forEach((key, index) => {
|
||||
pendingLabelState[key].index = index;
|
||||
});
|
||||
|
||||
// Set local state
|
||||
@@ -438,38 +426,6 @@ export function LogsTableWrap(props: Props) {
|
||||
updateExploreState(pendingLabelState);
|
||||
};
|
||||
|
||||
// uFuzzy search dispatcher, adds any matches to the local state
|
||||
const dispatcher = (data: string[][]) => {
|
||||
const matches = data[0];
|
||||
let newColumnsWithMeta: FieldNameMetaStore = {};
|
||||
let numberOfResults = 0;
|
||||
matches.forEach((match) => {
|
||||
if (match in columnsWithMeta) {
|
||||
newColumnsWithMeta[match] = columnsWithMeta[match];
|
||||
numberOfResults++;
|
||||
}
|
||||
});
|
||||
setFilteredColumnsWithMeta(newColumnsWithMeta);
|
||||
searchFilterEvent(numberOfResults);
|
||||
};
|
||||
|
||||
// uFuzzy search
|
||||
const search = (needle: string) => {
|
||||
fuzzySearch(Object.keys(columnsWithMeta), needle, dispatcher);
|
||||
};
|
||||
|
||||
// onChange handler for search input
|
||||
const onSearchInputChange = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
const value = e.currentTarget?.value;
|
||||
setSearchValue(value);
|
||||
if (value) {
|
||||
search(value);
|
||||
} else {
|
||||
// If the search input is empty, reset the local search state.
|
||||
setFilteredColumnsWithMeta(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const onFrameSelectorChange = (value: SelectableValue<string>) => {
|
||||
const matchingDataFrame = logsFrames.find((frame) => frame.refId === value.value);
|
||||
if (matchingDataFrame) {
|
||||
@@ -478,12 +434,14 @@ export function LogsTableWrap(props: Props) {
|
||||
props.updatePanelState({ refId: value.value, labelFieldName: logsFrame?.getLabelFieldName() ?? undefined });
|
||||
};
|
||||
|
||||
const styles = getStyles(props.theme, height, sidebarWidth);
|
||||
|
||||
const getOnResize: ResizeCallback = (event, direction, ref) => {
|
||||
const newSidebarWidth = Number(ref.style.width.slice(0, -2));
|
||||
if (!isNaN(newSidebarWidth)) {
|
||||
setSidebarWidth(newSidebarWidth);
|
||||
store.set(`${SETTING_KEY_ROOT}.fieldSelector.width`, newSidebarWidth);
|
||||
reportInteractionOnce('logs_field_selector_resized', {
|
||||
mode: 'table',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -522,33 +480,41 @@ export function LogsTableWrap(props: Props) {
|
||||
enable={{
|
||||
right: true,
|
||||
}}
|
||||
handleClasses={{ right: styles.rzHandle }}
|
||||
handleClasses={{ right: dragStyles.dragHandleVertical }}
|
||||
size={{ width: sidebarWidth, height: getLogsTableHeight() }}
|
||||
defaultSize={{ width: sidebarWidth, height: getLogsTableHeight() }}
|
||||
minWidth={MIN_WIDTH}
|
||||
maxWidth={props.width * 0.8}
|
||||
onResize={getOnResize}
|
||||
>
|
||||
<section className={styles.sidebar}>
|
||||
<LogsColumnSearch value={searchValue} onChange={onSearchInputChange} />
|
||||
<LogsTableMultiSelect
|
||||
reorderColumn={reorderColumn}
|
||||
toggleColumn={toggleColumn}
|
||||
filteredColumnsWithMeta={filteredColumnsWithMeta}
|
||||
columnsWithMeta={columnsWithMeta}
|
||||
clear={clearSelection}
|
||||
/>
|
||||
</section>
|
||||
<LogsTableFieldSelector
|
||||
clear={clearSelection}
|
||||
columnsWithMeta={columnsWithMeta}
|
||||
dataFrames={[currentDataFrame]}
|
||||
logs={[]}
|
||||
reorder={reorderColumn}
|
||||
setSidebarWidth={setSidebarWidth}
|
||||
sidebarWidth={sidebarWidth}
|
||||
toggle={toggleColumn}
|
||||
/>
|
||||
</Resizable>
|
||||
<LogsTable
|
||||
logsFrame={logsFrame}
|
||||
onClickFilterLabel={props.onClickFilterLabel}
|
||||
onClickFilterOutLabel={props.onClickFilterOutLabel}
|
||||
logsSortOrder={props.logsSortOrder}
|
||||
range={props.range}
|
||||
splitOpen={props.splitOpen}
|
||||
timeZone={props.timeZone}
|
||||
width={tableWidth}
|
||||
dataFrame={currentDataFrame}
|
||||
columnsWithMeta={columnsWithMeta}
|
||||
height={height}
|
||||
/>
|
||||
<div className={styles.tableContainer}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<LogsTable
|
||||
logsFrame={logsFrame}
|
||||
onClickFilterLabel={props.onClickFilterLabel}
|
||||
onClickFilterOutLabel={props.onClickFilterOutLabel}
|
||||
logsSortOrder={props.logsSortOrder}
|
||||
range={props.range}
|
||||
splitOpen={props.splitOpen}
|
||||
timeZone={props.timeZone}
|
||||
width={tableWidth}
|
||||
dataFrame={currentDataFrame}
|
||||
columnsWithMeta={columnsWithMeta}
|
||||
height={height}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -570,21 +536,15 @@ function getStyles(theme: GrafanaTheme2, height: number, width: number) {
|
||||
width: width,
|
||||
paddingRight: theme.spacing(3),
|
||||
}),
|
||||
rzHandle: css({
|
||||
background: theme.colors.secondary.main,
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
transition: '0.3s background ease-in-out',
|
||||
},
|
||||
tableContainer: css({
|
||||
position: 'relative',
|
||||
height: '50% !important',
|
||||
width: `${theme.spacing(1)} !important`,
|
||||
top: '25% !important',
|
||||
right: `${theme.spacing(1)} !important`,
|
||||
cursor: 'grab',
|
||||
borderRadius: theme.shape.radius.pill,
|
||||
['&:hover']: {
|
||||
background: theme.colors.secondary.shade,
|
||||
},
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
}),
|
||||
tableWrapper: css({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { EventBusSrv, GrafanaTheme2 } from '@grafana/data';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { LogsTableWrap } from '../../explore/Logs/LogsTableWrap';
|
||||
|
||||
@@ -30,8 +30,7 @@ export const ControlledLogsTable = ({
|
||||
const eventBus = useMemo(() => new EventBusSrv(), []);
|
||||
const ref = useRef(null);
|
||||
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!splitOpen || !width || !updatePanelState) {
|
||||
console.error('<ControlledLogsTable>: Missing required props.');
|
||||
@@ -56,7 +55,6 @@ export const ControlledLogsTable = ({
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
panelState={panelState}
|
||||
theme={theme}
|
||||
updatePanelState={updatePanelState}
|
||||
datasourceType={datasourceType}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { DragDropContext, Draggable, DraggableProvided, Droppable, DropResult } from '@hello-pangea/dnd';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { Field } from './Field';
|
||||
import { FieldWithStats } from './FieldSelector';
|
||||
|
||||
interface Props {
|
||||
activeFields: string[];
|
||||
clear(): void;
|
||||
fields: FieldWithStats[];
|
||||
reorder: (columns: string[]) => void;
|
||||
suggestedFields: FieldWithStats[];
|
||||
toggle: (key: string) => void;
|
||||
}
|
||||
|
||||
export const ActiveFields = ({ activeFields, clear, fields, reorder, suggestedFields, toggle }: Props) => {
|
||||
const styles = useStyles2(getLogsFieldsStyles);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
const newActiveFields = [...activeFields];
|
||||
const element = activeFields[result.source.index];
|
||||
|
||||
newActiveFields.splice(result.source.index, 1);
|
||||
newActiveFields.splice(result.destination.index, 0, element);
|
||||
|
||||
reorder(newActiveFields);
|
||||
},
|
||||
[activeFields, reorder]
|
||||
);
|
||||
|
||||
const active = useMemo(
|
||||
() => [
|
||||
...activeFields
|
||||
.map(
|
||||
(name) => fields.find((field) => field.name === name) ?? suggestedFields.find((field) => field.name === name)
|
||||
)
|
||||
.filter((field) => field !== undefined),
|
||||
...suggestedFields.filter((suggestedField) => !activeFields.includes(suggestedField.name)),
|
||||
],
|
||||
[activeFields, fields, suggestedFields]
|
||||
);
|
||||
|
||||
if (active.length) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.columnHeader}>
|
||||
<Trans i18nKey="explore.logs-table-multi-select.selected-fields">Selected fields</Trans>
|
||||
<button onClick={clear} className={styles.columnHeaderButton}>
|
||||
<Trans i18nKey="explore.logs-table-multi-select.reset">Reset</Trans>
|
||||
</button>
|
||||
</div>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="order-fields" direction="vertical">
|
||||
{(provided) => (
|
||||
<div className={styles.columnWrapper} {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{active.map((field, index) => (
|
||||
<Draggable
|
||||
draggableId={field.name}
|
||||
key={field.name}
|
||||
index={index}
|
||||
isDragDisabled={!activeFields.includes(field.name)}
|
||||
>
|
||||
{(provided: DraggableProvided, snapshot) => (
|
||||
<div
|
||||
className={cx(styles.wrap, snapshot.isDragging ? styles.dragging : undefined)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
title={t(
|
||||
'logs.field-selector.label-title',
|
||||
`{{fieldName}} appears in {{percentage}}% of log lines`,
|
||||
{ fieldName: field.name, percentage: field.stats.percentOfLinesWithLabel }
|
||||
)}
|
||||
>
|
||||
<Field
|
||||
active={activeFields.includes(field.name)}
|
||||
field={field}
|
||||
toggle={toggle}
|
||||
draggable={activeFields.includes(field.name)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export function getLogsFieldsStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrap: css({
|
||||
marginTop: theme.spacing(1),
|
||||
marginBottom: theme.spacing(1),
|
||||
display: 'flex',
|
||||
background: theme.colors.background.primary,
|
||||
}),
|
||||
dragging: css({
|
||||
background: theme.colors.background.secondary,
|
||||
}),
|
||||
columnHeader: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: theme.typography.h6.fontSize,
|
||||
background: theme.colors.background.secondary,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
left: 0,
|
||||
paddingTop: theme.spacing(0.75),
|
||||
paddingRight: theme.spacing(0.75),
|
||||
paddingBottom: theme.spacing(0.75),
|
||||
paddingLeft: theme.spacing(1.5),
|
||||
zIndex: 3,
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
columnHeaderButton: css({
|
||||
appearance: 'none',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: theme.typography.pxToRem(11),
|
||||
}),
|
||||
columnWrapper: css({
|
||||
marginBottom: theme.spacing(1.5),
|
||||
// need some space or the outline of the checkbox is cut off
|
||||
paddingLeft: theme.spacing(0.5),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { getLogsFieldsStyles } from './ActiveFields';
|
||||
import { EmptyFields } from './EmptyFields';
|
||||
import { Field } from './Field';
|
||||
import { FieldWithStats } from './FieldSelector';
|
||||
|
||||
interface Props {
|
||||
activeFields: string[];
|
||||
fields: FieldWithStats[];
|
||||
toggle: (key: string) => void;
|
||||
reorder: (columns: string[]) => void;
|
||||
}
|
||||
|
||||
export const AvailableFields = ({ activeFields, fields, toggle, reorder }: Props): JSX.Element => {
|
||||
const styles = useStyles2(getLogsFieldsStyles);
|
||||
|
||||
const availableFields = useMemo(
|
||||
() => fields.filter((field) => !activeFields.includes(field.name)).sort(sortFields),
|
||||
[activeFields, fields]
|
||||
);
|
||||
|
||||
if (availableFields.length) {
|
||||
return (
|
||||
<div className={styles.columnWrapper}>
|
||||
{availableFields.map((field) => (
|
||||
<div
|
||||
key={field.name}
|
||||
className={styles.wrap}
|
||||
title={t('logs.field-selector.label-title', `{{fieldName}} appears in {{percentage}}% of log lines`, {
|
||||
fieldName: field.name,
|
||||
percentage: field.stats.percentOfLinesWithLabel,
|
||||
})}
|
||||
>
|
||||
<Field field={field} toggle={toggle} showCount />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <EmptyFields />;
|
||||
};
|
||||
|
||||
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
|
||||
|
||||
function sortFields(a: FieldWithStats, b: FieldWithStats) {
|
||||
return collator.compare(a.name, b.name);
|
||||
}
|
||||
+3
-4
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
@@ -14,9 +14,8 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
};
|
||||
}
|
||||
|
||||
export function LogsTableEmptyFields() {
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
export function EmptyFields() {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<Trans i18nKey="explore.logs-table-empty-fields.no-fields">No fields</Trans>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { css } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { Checkbox, Icon, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { getNormalizedFieldName } from '../panel/processing';
|
||||
|
||||
import { FieldWithStats } from './FieldSelector';
|
||||
|
||||
interface Props {
|
||||
active?: boolean;
|
||||
field: FieldWithStats;
|
||||
toggle(key: string): void;
|
||||
draggable?: boolean;
|
||||
showCount?: boolean;
|
||||
}
|
||||
|
||||
export function Field({
|
||||
active = false,
|
||||
draggable = false,
|
||||
field,
|
||||
toggle,
|
||||
showCount = false,
|
||||
}: Props): React.JSX.Element | undefined {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
reportInteraction('logs_field_selector_toggle_fields_clicked', {
|
||||
active,
|
||||
});
|
||||
toggle(field.name);
|
||||
}, [active, field.name, toggle]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.contentWrap}>
|
||||
<Checkbox
|
||||
className={styles.checkboxLabel}
|
||||
label={getNormalizedFieldName(field.name)}
|
||||
onChange={handleChange}
|
||||
checked={active}
|
||||
/>
|
||||
{showCount && (
|
||||
<button className={styles.labelCount} onClick={handleChange}>
|
||||
{field.stats.percentOfLinesWithLabel}%
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{draggable && (
|
||||
<Icon
|
||||
aria-label={t('logs.field-selector.aria-label-drag-and-drop-icon', 'Drag and drop icon')}
|
||||
title={t('logs.field-selector.title-drag-and-drop-to-reorder', 'Drag and drop to reorder')}
|
||||
name="draggabledots"
|
||||
size="lg"
|
||||
className={styles.dragIcon}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
dragIcon: css({
|
||||
cursor: 'drag',
|
||||
marginLeft: theme.spacing(1),
|
||||
opacity: 0.4,
|
||||
}),
|
||||
labelCount: css({
|
||||
marginLeft: theme.spacing(0.5),
|
||||
marginRight: theme.spacing(0.5),
|
||||
appearance: 'none',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: theme.typography.pxToRem(11),
|
||||
opacity: 0.6,
|
||||
}),
|
||||
contentWrap: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
}),
|
||||
// Hide text that overflows, had to select elements within the Checkbox component, so this is a bit fragile
|
||||
checkboxLabel: css({
|
||||
'> span': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { ActiveFields } from './ActiveFields';
|
||||
import { AvailableFields } from './AvailableFields';
|
||||
import { FieldWithStats } from './FieldSelector';
|
||||
|
||||
interface Props {
|
||||
activeFields: string[];
|
||||
clear: () => void;
|
||||
fields: FieldWithStats[];
|
||||
reorder: (columns: string[]) => void;
|
||||
suggestedFields: FieldWithStats[];
|
||||
toggle: (columnName: string) => void;
|
||||
}
|
||||
|
||||
export const FieldList = ({ activeFields, clear, fields, reorder, suggestedFields, toggle }: Props) => {
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={styles.sidebarWrap}>
|
||||
{/* Sidebar columns */}
|
||||
<>
|
||||
<ActiveFields
|
||||
activeFields={activeFields}
|
||||
clear={clear}
|
||||
fields={fields}
|
||||
reorder={reorder}
|
||||
suggestedFields={suggestedFields}
|
||||
toggle={toggle}
|
||||
/>
|
||||
|
||||
<div className={styles.columnHeader}>
|
||||
<Trans i18nKey="explore.logs-table-multi-select.fields">Fields</Trans>
|
||||
</div>
|
||||
<AvailableFields activeFields={activeFields} fields={fields} reorder={reorder} toggle={toggle} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
sidebarWrap: css({
|
||||
overflowY: 'scroll',
|
||||
flex: 1,
|
||||
/* Hide scrollbar for Chrome, Safari, and Opera */
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
/* Hide scrollbar for Firefox */
|
||||
scrollbarWidth: 'none',
|
||||
}),
|
||||
columnHeader: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: theme.typography.h6.fontSize,
|
||||
background: theme.colors.background.secondary,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
left: 0,
|
||||
paddingTop: theme.spacing(0.75),
|
||||
paddingRight: theme.spacing(0.75),
|
||||
paddingBottom: theme.spacing(0.75),
|
||||
paddingLeft: theme.spacing(1.5),
|
||||
zIndex: 3,
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { css } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Field, IconButton, Input, useStyles2 } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
collapse(): void;
|
||||
onChange(e?: React.FormEvent<HTMLInputElement>): void;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function FieldSearch({ collapse, onChange, value }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
className={styles.iconExpanded}
|
||||
onClick={collapse}
|
||||
name="arrow-from-right"
|
||||
tooltip={t('logs.field-selector.collapse', 'Collapse sidebar')}
|
||||
size="sm"
|
||||
/>
|
||||
<Field noMargin className={styles.searchWrap}>
|
||||
<Input
|
||||
value={value}
|
||||
type="text"
|
||||
placeholder={t('logs.field-selector.placeholder-search-fields-by-name', 'Search fields by name')}
|
||||
onChange={onChange}
|
||||
suffix={
|
||||
value ? (
|
||||
<IconButton
|
||||
name="times"
|
||||
aria-label={t('logs.field-selector.clear-button', 'Clear')}
|
||||
onClick={() => onChange()}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
iconExpanded: css({
|
||||
position: 'absolute',
|
||||
right: theme.spacing(0.2),
|
||||
top: theme.spacing(1),
|
||||
svg: {
|
||||
transform: 'rotate(-180deg)',
|
||||
},
|
||||
}),
|
||||
searchWrap: css({
|
||||
padding: `${theme.spacing(0.4)} 0 ${theme.spacing(0.4)} ${theme.spacing(0.4)}`,
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { DataFrame, FieldType, store, toDataFrame } from '@grafana/data';
|
||||
import { FieldNameMetaStore } from 'app/features/explore/Logs/LogsTableWrap';
|
||||
|
||||
import { createLogLine } from '../mocks/logRow';
|
||||
import { LogListContext } from '../panel/LogListContext';
|
||||
import { defaultValue } from '../panel/__mocks__/LogListContext';
|
||||
import { LogListModel } from '../panel/processing';
|
||||
|
||||
import { LogListFieldSelector, LogsTableFieldSelector, MIN_WIDTH } from './FieldSelector';
|
||||
|
||||
let containerElement: HTMLDivElement;
|
||||
let logs: LogListModel[];
|
||||
let dataFrames: DataFrame[];
|
||||
|
||||
beforeEach(() => {
|
||||
containerElement = document.createElement('div');
|
||||
containerElement.style.height = '500px';
|
||||
containerElement.style.width = '1000px';
|
||||
|
||||
logs = [
|
||||
createLogLine({ uid: '1', entry: 'log 1', labels: { service: 'frontend', level: 'info' } }),
|
||||
createLogLine({ uid: '2', entry: 'log 2', labels: { service: 'backend', level: 'error' } }),
|
||||
];
|
||||
|
||||
dataFrames = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'timestamp', type: FieldType.time, values: [1, 2] },
|
||||
{ name: 'body', type: FieldType.string, values: ['log 1', 'log 2'] },
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
values: [
|
||||
{ service: 'frontend', level: 'info' },
|
||||
{ service: 'backend', level: 'error' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
jest.spyOn(store, 'get').mockReturnValue('220');
|
||||
});
|
||||
|
||||
describe('LogListFieldSelector', () => {
|
||||
const onClickShowField = jest.fn();
|
||||
const onClickHideField = jest.fn();
|
||||
const setDisplayedFields = jest.fn();
|
||||
|
||||
const storageKey = 'test-storage-key';
|
||||
|
||||
const defaultContextValue = {
|
||||
...defaultValue,
|
||||
displayedFields: ['service'],
|
||||
onClickShowField,
|
||||
onClickHideField,
|
||||
setDisplayedFields,
|
||||
logOptionsStorageKey: storageKey,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render field selector when width is sufficient', () => {
|
||||
render(
|
||||
<LogListContext.Provider value={defaultContextValue}>
|
||||
<LogListFieldSelector containerElement={containerElement} logs={logs} dataFrames={dataFrames} />
|
||||
</LogListContext.Provider>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText('Search fields by name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render collapsed button when width is too small', async () => {
|
||||
jest.spyOn(store, 'get').mockReturnValue(String(MIN_WIDTH));
|
||||
|
||||
render(
|
||||
<LogListContext.Provider value={defaultContextValue}>
|
||||
<LogListFieldSelector containerElement={containerElement} logs={logs} dataFrames={dataFrames} />
|
||||
</LogListContext.Provider>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('Expand sidebar')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('Search fields by name')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Expand sidebar'));
|
||||
|
||||
expect(screen.getByPlaceholderText('Search fields by name')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Expand sidebar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render with default width without a storage key', () => {
|
||||
render(
|
||||
<LogListContext.Provider value={{ ...defaultContextValue, logOptionsStorageKey: undefined }}>
|
||||
<LogListFieldSelector containerElement={containerElement} logs={logs} dataFrames={dataFrames} />
|
||||
</LogListContext.Provider>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText('Search fields by name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call clear when reset button is clicked', async () => {
|
||||
render(
|
||||
<LogListContext.Provider value={defaultContextValue}>
|
||||
<LogListFieldSelector containerElement={containerElement} logs={logs} dataFrames={dataFrames} />
|
||||
</LogListContext.Provider>
|
||||
);
|
||||
|
||||
const resetButton = screen.getByText('Reset');
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
expect(setDisplayedFields).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
test('should not render the selected fields without selected fields', () => {
|
||||
render(
|
||||
<LogListContext.Provider value={{ ...defaultContextValue, displayedFields: [] }}>
|
||||
<LogListFieldSelector containerElement={containerElement} logs={logs} dataFrames={dataFrames} />
|
||||
</LogListContext.Provider>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Reset')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Selected fields')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should persist sidebar width to storage', async () => {
|
||||
const storeSpy = jest.spyOn(store, 'set');
|
||||
|
||||
render(
|
||||
<LogListContext.Provider value={defaultContextValue}>
|
||||
<LogListFieldSelector containerElement={containerElement} logs={logs} dataFrames={dataFrames} />
|
||||
</LogListContext.Provider>
|
||||
);
|
||||
|
||||
const collapseButton = screen.getByLabelText('Collapse sidebar');
|
||||
await userEvent.click(collapseButton);
|
||||
|
||||
expect(storeSpy).toHaveBeenCalledWith(`${storageKey}.fieldSelector.width`, MIN_WIDTH);
|
||||
});
|
||||
|
||||
test('should show selected fields and available fields', async () => {
|
||||
onClickHideField.mockClear();
|
||||
onClickShowField.mockClear();
|
||||
|
||||
render(
|
||||
<LogListContext.Provider value={defaultContextValue}>
|
||||
<LogListFieldSelector containerElement={containerElement} logs={logs} dataFrames={dataFrames} />
|
||||
</LogListContext.Provider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('service')).toBeInTheDocument();
|
||||
expect(screen.getByText('level')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('service'));
|
||||
await userEvent.click(screen.getByText('level'));
|
||||
|
||||
expect(onClickShowField).toHaveBeenCalledWith('level');
|
||||
expect(onClickHideField).toHaveBeenCalledWith('service');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LogsTableFieldSelector', () => {
|
||||
const clear = jest.fn();
|
||||
const reorder = jest.fn();
|
||||
const setSidebarWidth = jest.fn();
|
||||
const toggle = jest.fn();
|
||||
let columnsWithMeta: FieldNameMetaStore = {};
|
||||
|
||||
beforeEach(() => {
|
||||
columnsWithMeta = {
|
||||
timestamp: { active: true, index: 0, type: 'TIME_FIELD', percentOfLinesWithLabel: 100 },
|
||||
body: { active: true, index: 1, type: 'BODY_FIELD', percentOfLinesWithLabel: 100 },
|
||||
service: { active: true, index: 2, percentOfLinesWithLabel: 50 },
|
||||
level: { active: false, index: undefined, percentOfLinesWithLabel: 50 },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render field selector when width is sufficient', () => {
|
||||
render(
|
||||
<LogsTableFieldSelector
|
||||
columnsWithMeta={columnsWithMeta}
|
||||
clear={clear}
|
||||
dataFrames={dataFrames}
|
||||
logs={logs}
|
||||
reorder={reorder}
|
||||
setSidebarWidth={setSidebarWidth}
|
||||
sidebarWidth={300}
|
||||
toggle={toggle}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText('Search fields by name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render collapsed button when width is too small', async () => {
|
||||
const storeSpy = jest.spyOn(store, 'set');
|
||||
|
||||
render(
|
||||
<LogsTableFieldSelector
|
||||
columnsWithMeta={columnsWithMeta}
|
||||
clear={clear}
|
||||
dataFrames={dataFrames}
|
||||
logs={logs}
|
||||
reorder={reorder}
|
||||
setSidebarWidth={setSidebarWidth}
|
||||
sidebarWidth={MIN_WIDTH}
|
||||
toggle={toggle}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('Expand sidebar')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('Search fields by name')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Expand sidebar'));
|
||||
|
||||
expect(setSidebarWidth).toHaveBeenCalledWith(220);
|
||||
expect(storeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should show selected fields and available fields', async () => {
|
||||
toggle.mockClear();
|
||||
|
||||
render(
|
||||
<LogsTableFieldSelector
|
||||
columnsWithMeta={columnsWithMeta}
|
||||
clear={clear}
|
||||
dataFrames={dataFrames}
|
||||
logs={logs}
|
||||
reorder={reorder}
|
||||
setSidebarWidth={setSidebarWidth}
|
||||
sidebarWidth={300}
|
||||
toggle={toggle}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Selected fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reset')).toBeInTheDocument();
|
||||
expect(screen.getByText('timestamp')).toBeInTheDocument();
|
||||
expect(screen.getByText('body')).toBeInTheDocument();
|
||||
expect(screen.getByText('service')).toBeInTheDocument();
|
||||
expect(screen.getByText('level')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('service'));
|
||||
await userEvent.click(screen.getByText('level'));
|
||||
|
||||
expect(toggle).toHaveBeenCalledWith('service');
|
||||
expect(toggle).toHaveBeenCalledWith('level');
|
||||
});
|
||||
|
||||
test('should call clear when reset button is clicked', async () => {
|
||||
render(
|
||||
<LogsTableFieldSelector
|
||||
columnsWithMeta={columnsWithMeta}
|
||||
clear={clear}
|
||||
dataFrames={dataFrames}
|
||||
logs={logs}
|
||||
reorder={reorder}
|
||||
setSidebarWidth={setSidebarWidth}
|
||||
sidebarWidth={300}
|
||||
toggle={toggle}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Reset'));
|
||||
|
||||
expect(clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should persist sidebar width to storage when collapsing', async () => {
|
||||
const storeSpy = jest.spyOn(store, 'set');
|
||||
|
||||
render(
|
||||
<LogsTableFieldSelector
|
||||
columnsWithMeta={columnsWithMeta}
|
||||
clear={clear}
|
||||
dataFrames={dataFrames}
|
||||
logs={logs}
|
||||
reorder={reorder}
|
||||
setSidebarWidth={setSidebarWidth}
|
||||
sidebarWidth={300}
|
||||
toggle={toggle}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Collapse sidebar'));
|
||||
|
||||
expect(setSidebarWidth).toHaveBeenCalledWith(MIN_WIDTH);
|
||||
expect(storeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Resizable, ResizeCallback } from 're-resizable';
|
||||
import { startTransition, useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { DataFrame, fuzzySearch, GrafanaTheme2, store } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { getDragStyles, IconButton, useStyles2 } from '@grafana/ui';
|
||||
import { FieldNameMetaStore } from 'app/features/explore/Logs/LogsTableWrap';
|
||||
import { SETTING_KEY_ROOT } from 'app/features/explore/Logs/utils/logs';
|
||||
import { parseLogsFrame } from 'app/features/logs/logsFrame';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { getSuggestedFieldsForLogs } from '../otel/formats';
|
||||
import { useLogListContext } from '../panel/LogListContext';
|
||||
import { reportInteractionOnce } from '../panel/analytics';
|
||||
import { LogListModel } from '../panel/processing';
|
||||
|
||||
import { FieldList } from './FieldList';
|
||||
import { FieldSearch } from './FieldSearch';
|
||||
|
||||
/**
|
||||
* FieldSelector wrapper for the LogList visualization.
|
||||
*/
|
||||
interface LogListFieldSelectorProps {
|
||||
containerElement: HTMLDivElement;
|
||||
logs: LogListModel[];
|
||||
dataFrames: DataFrame[];
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = 220;
|
||||
export const MIN_WIDTH = 20;
|
||||
|
||||
export const LogListFieldSelector = ({ containerElement, dataFrames, logs }: LogListFieldSelectorProps) => {
|
||||
const { displayedFields, onClickShowField, onClickHideField, setDisplayedFields, logOptionsStorageKey } =
|
||||
useLogListContext();
|
||||
const [sidebarHeight, setSidebarHeight] = useState(220);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(getSidebarWidth(logOptionsStorageKey));
|
||||
const dragStyles = useStyles2(getDragStyles);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
if (entries.length) {
|
||||
setSidebarHeight(entries[0].contentRect.height);
|
||||
}
|
||||
});
|
||||
observer.observe(containerElement);
|
||||
return () => observer.disconnect();
|
||||
}, [containerElement]);
|
||||
|
||||
const setSidebarWidthWrapper = useCallback(
|
||||
(width: number) => {
|
||||
setSidebarWidth(width);
|
||||
if (logOptionsStorageKey) {
|
||||
store.set(`${logOptionsStorageKey}.fieldSelector.width`, width);
|
||||
}
|
||||
},
|
||||
[logOptionsStorageKey]
|
||||
);
|
||||
|
||||
const clearFields = useCallback(() => {
|
||||
setDisplayedFields?.([]);
|
||||
reportInteraction('logs_field_selector_clear_fields_clicked', {
|
||||
fields: displayedFields.length,
|
||||
mode: 'logs',
|
||||
});
|
||||
}, [displayedFields.length, setDisplayedFields]);
|
||||
|
||||
const collapse = useCallback(() => {
|
||||
setSidebarWidthWrapper(MIN_WIDTH);
|
||||
reportInteraction('logs_field_selector_collapse_clicked', {
|
||||
mode: 'logs',
|
||||
});
|
||||
}, [setSidebarWidthWrapper]);
|
||||
|
||||
const expand = useCallback(() => {
|
||||
const width = getSidebarWidth(logOptionsStorageKey);
|
||||
setSidebarWidthWrapper(width < 2 * MIN_WIDTH ? DEFAULT_WIDTH : width);
|
||||
reportInteraction('logs_field_selector_expand_clicked', {
|
||||
mode: 'logs',
|
||||
});
|
||||
}, [logOptionsStorageKey, setSidebarWidthWrapper]);
|
||||
|
||||
const handleResize: ResizeCallback = useCallback(
|
||||
(event, direction, ref) => {
|
||||
setSidebarWidthWrapper(ref.clientWidth);
|
||||
reportInteractionOnce('logs_field_selector_resized', {
|
||||
mode: 'logs',
|
||||
});
|
||||
},
|
||||
[setSidebarWidthWrapper]
|
||||
);
|
||||
|
||||
const toggleField = useCallback(
|
||||
(name: string) => {
|
||||
if (displayedFields.includes(name)) {
|
||||
onClickHideField?.(name);
|
||||
} else {
|
||||
onClickShowField?.(name);
|
||||
}
|
||||
},
|
||||
[displayedFields, onClickHideField, onClickShowField]
|
||||
);
|
||||
|
||||
const suggestedFields = useMemo(() => getSuggestedFields(logs, displayedFields), [displayedFields, logs]);
|
||||
const fields = useMemo(() => getFieldsWithStats(dataFrames), [dataFrames]);
|
||||
|
||||
if (!onClickShowField || !onClickHideField || !setDisplayedFields) {
|
||||
console.warn(
|
||||
'LogListFieldSelector: Missing required props: onClickShowField, onClickHideField, setDisplayedFields'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (sidebarHeight === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
enable={{
|
||||
right: true,
|
||||
}}
|
||||
handleClasses={{ right: dragStyles.dragHandleVertical }}
|
||||
size={{ width: sidebarWidth, height: sidebarHeight }}
|
||||
defaultSize={{ width: sidebarWidth, height: sidebarHeight }}
|
||||
minWidth={MIN_WIDTH}
|
||||
maxWidth={containerElement.clientWidth * 0.8}
|
||||
onResize={handleResize}
|
||||
>
|
||||
{sidebarWidth > MIN_WIDTH * 2 ? (
|
||||
<FieldSelector
|
||||
activeFields={displayedFields}
|
||||
clear={clearFields}
|
||||
collapse={collapse}
|
||||
fields={fields}
|
||||
reorder={setDisplayedFields}
|
||||
suggestedFields={suggestedFields}
|
||||
toggle={toggleField}
|
||||
/>
|
||||
) : (
|
||||
<div className={logsFieldSelectorWrapperStyles.collapsedButtonContainer}>
|
||||
<IconButton
|
||||
className={logsFieldSelectorWrapperStyles.collapsedButton}
|
||||
onClick={expand}
|
||||
name="arrow-from-right"
|
||||
tooltip={t('logs.field-selector.expand', 'Expand sidebar')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Resizable>
|
||||
);
|
||||
};
|
||||
|
||||
const logsFieldSelectorWrapperStyles = {
|
||||
collapsedButtonContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
paddingTop: 2,
|
||||
}),
|
||||
collapsedButton: css({
|
||||
margin: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* FieldSelector wrapper for the LogsTable visualization.
|
||||
*/
|
||||
interface LogsTableFieldSelectorProps {
|
||||
columnsWithMeta: FieldNameMetaStore;
|
||||
clear(): void;
|
||||
dataFrames: DataFrame[];
|
||||
logs: LogListModel[];
|
||||
reorder(columns: string[]): void;
|
||||
setSidebarWidth(width: number): void;
|
||||
sidebarWidth: number;
|
||||
toggle(key: string): void;
|
||||
}
|
||||
|
||||
export const LogsTableFieldSelector = ({
|
||||
columnsWithMeta,
|
||||
clear: clearProp,
|
||||
dataFrames,
|
||||
logs,
|
||||
reorder,
|
||||
setSidebarWidth,
|
||||
sidebarWidth,
|
||||
toggle,
|
||||
}: LogsTableFieldSelectorProps) => {
|
||||
const setSidebarWidthWrapper = useCallback(
|
||||
(width: number) => {
|
||||
setSidebarWidth(width);
|
||||
store.set(`${SETTING_KEY_ROOT}.fieldSelector.width`, width);
|
||||
},
|
||||
[setSidebarWidth]
|
||||
);
|
||||
|
||||
const collapse = useCallback(() => {
|
||||
setSidebarWidthWrapper(MIN_WIDTH);
|
||||
reportInteraction('logs_field_selector_collapse_clicked', {
|
||||
mode: 'table',
|
||||
});
|
||||
}, [setSidebarWidthWrapper]);
|
||||
|
||||
const expand = useCallback(() => {
|
||||
const width = getSidebarWidth(SETTING_KEY_ROOT);
|
||||
setSidebarWidthWrapper(width < 2 * MIN_WIDTH ? DEFAULT_WIDTH : width);
|
||||
reportInteraction('logs_field_selector_expand_clicked', {
|
||||
mode: 'table',
|
||||
});
|
||||
}, [setSidebarWidthWrapper]);
|
||||
|
||||
const displayedColumns = useMemo(
|
||||
() =>
|
||||
Object.keys(columnsWithMeta)
|
||||
.filter((column) => columnsWithMeta[column].active)
|
||||
.sort((a, b) =>
|
||||
columnsWithMeta[a].index !== undefined && columnsWithMeta[b].index !== undefined
|
||||
? columnsWithMeta[a].index - columnsWithMeta[b].index
|
||||
: 0
|
||||
),
|
||||
[columnsWithMeta]
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
clearProp();
|
||||
reportInteraction('logs_field_selector_clear_fields_clicked', {
|
||||
fields: displayedColumns.length,
|
||||
mode: 'table',
|
||||
});
|
||||
}, [clearProp, displayedColumns.length]);
|
||||
|
||||
const defaultColumns = useMemo(
|
||||
() =>
|
||||
Object.keys(columnsWithMeta)
|
||||
.sort((a, b) =>
|
||||
columnsWithMeta[a].index !== undefined && columnsWithMeta[b].index !== undefined
|
||||
? columnsWithMeta[a].index - columnsWithMeta[b].index
|
||||
: 0
|
||||
)
|
||||
.filter(
|
||||
(column) => columnsWithMeta[column].type === 'TIME_FIELD' || columnsWithMeta[column].type === 'BODY_FIELD'
|
||||
),
|
||||
[columnsWithMeta]
|
||||
);
|
||||
|
||||
const suggestedFields = useMemo(
|
||||
() => getSuggestedFields(logs, displayedColumns, defaultColumns),
|
||||
[defaultColumns, displayedColumns, logs]
|
||||
);
|
||||
const fields = useMemo(() => getFieldsWithStats(dataFrames), [dataFrames]);
|
||||
|
||||
return sidebarWidth > MIN_WIDTH * 2 ? (
|
||||
<FieldSelector
|
||||
activeFields={displayedColumns}
|
||||
clear={clear}
|
||||
collapse={collapse}
|
||||
fields={fields}
|
||||
reorder={reorder}
|
||||
suggestedFields={suggestedFields}
|
||||
toggle={toggle}
|
||||
/>
|
||||
) : (
|
||||
<div className={logsFieldSelectorWrapperStyles.collapsedButtonContainer}>
|
||||
<IconButton
|
||||
className={logsFieldSelectorWrapperStyles.collapsedButton}
|
||||
onClick={expand}
|
||||
name="arrow-from-right"
|
||||
tooltip={t('logs.field-selector.expand', 'Expand sidebar')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FieldStats {
|
||||
percentOfLinesWithLabel: number;
|
||||
}
|
||||
|
||||
export interface FieldWithStats {
|
||||
name: string;
|
||||
stats: FieldStats;
|
||||
}
|
||||
|
||||
export interface FieldSelectorProps {
|
||||
activeFields: string[];
|
||||
clear(): void;
|
||||
collapse(): void;
|
||||
fields: FieldWithStats[];
|
||||
reorder(fields: string[]): void;
|
||||
suggestedFields: FieldWithStats[];
|
||||
toggle: (key: string) => void;
|
||||
}
|
||||
|
||||
export const FieldSelector = ({
|
||||
activeFields,
|
||||
clear,
|
||||
collapse,
|
||||
fields,
|
||||
reorder,
|
||||
suggestedFields,
|
||||
toggle,
|
||||
}: FieldSelectorProps) => {
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const onSearchInputChange = useCallback((e?: React.FormEvent<HTMLInputElement>) => {
|
||||
if (e === undefined) {
|
||||
setSearchValue('');
|
||||
return;
|
||||
}
|
||||
startTransition(() => {
|
||||
setSearchValue(e.currentTarget.value);
|
||||
});
|
||||
reportInteractionOnce('logs_field_selector_text_search');
|
||||
}, []);
|
||||
|
||||
const filteredFields = useMemo(() => {
|
||||
if (!searchValue) {
|
||||
return fields;
|
||||
}
|
||||
const idxs = fuzzySearch(
|
||||
fields.map((field) => field.name),
|
||||
searchValue
|
||||
);
|
||||
return fields.filter((_, index) => idxs.includes(index));
|
||||
}, [fields, searchValue]);
|
||||
|
||||
const filteredSuggestedFields = useMemo(() => {
|
||||
if (!searchValue) {
|
||||
return suggestedFields;
|
||||
}
|
||||
const idxs = fuzzySearch(
|
||||
suggestedFields.map((field) => field.name),
|
||||
searchValue
|
||||
);
|
||||
return suggestedFields.filter((_, index) => idxs.includes(index));
|
||||
}, [searchValue, suggestedFields]);
|
||||
|
||||
return (
|
||||
<section className={styles.sidebar}>
|
||||
<FieldSearch collapse={collapse} onChange={onSearchInputChange} value={searchValue} />
|
||||
<FieldList
|
||||
activeFields={activeFields}
|
||||
clear={clear}
|
||||
fields={filteredFields}
|
||||
reorder={reorder}
|
||||
suggestedFields={filteredSuggestedFields}
|
||||
toggle={toggle}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
sidebar: css({
|
||||
fontSize: theme.typography.pxToRem(11),
|
||||
paddingRight: theme.spacing(3),
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function getFieldsWithStats(dataFrames: DataFrame[]): FieldWithStats[] {
|
||||
const cardinality = new Map<string, number>();
|
||||
let totalLines = 0;
|
||||
const allFields = dataFrames.flatMap((dataFrame) => {
|
||||
const logsFrame = parseLogsFrame(dataFrame);
|
||||
totalLines += dataFrame.length;
|
||||
|
||||
const labelValues = logsFrame?.getLogFrameLabelsAsLabels();
|
||||
const labels =
|
||||
labelValues?.flatMap((labels) => {
|
||||
const keys = Object.keys(labels);
|
||||
keys.forEach((key) => cardinality.set(key, (cardinality.get(key) ?? 0) + 1));
|
||||
return keys;
|
||||
}) ?? [];
|
||||
|
||||
const fields = (logsFrame?.extraFields ?? [])
|
||||
.filter((field) => !field?.config?.custom?.hidden)
|
||||
.map((field) => {
|
||||
cardinality.set(field.name, field.values.filter((value) => value !== null && value !== undefined).length);
|
||||
return field.name;
|
||||
});
|
||||
|
||||
return [...labels, ...fields];
|
||||
});
|
||||
|
||||
const labels = [...new Set(allFields)];
|
||||
|
||||
return labels.map((label) => ({
|
||||
name: label,
|
||||
stats: {
|
||||
percentOfLinesWithLabel: Math.ceil((100 * (cardinality.get(label) ?? 0)) / totalLines),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function getSuggestedFields(logs: LogListModel[], displayedFields: string[], defaultFields: string[] = []) {
|
||||
const suggestedFields: FieldWithStats[] = defaultFields.map((field) => ({
|
||||
name: field,
|
||||
stats: {
|
||||
percentOfLinesWithLabel: 100,
|
||||
},
|
||||
}));
|
||||
if (config.featureToggles.otelLogsFormatting) {
|
||||
getSuggestedFieldsForLogs(logs).forEach((field) => {
|
||||
suggestedFields.push({
|
||||
name: field,
|
||||
stats: {
|
||||
percentOfLinesWithLabel: 100,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!defaultFields.length &&
|
||||
displayedFields.length &&
|
||||
!suggestedFields.find((field) => field.name === LOG_LINE_BODY_FIELD_NAME)
|
||||
) {
|
||||
suggestedFields.push({
|
||||
name: LOG_LINE_BODY_FIELD_NAME,
|
||||
stats: {
|
||||
percentOfLinesWithLabel: 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return suggestedFields;
|
||||
}
|
||||
|
||||
export function getSidebarWidth(logOptionsStorageKey?: string): number {
|
||||
const width =
|
||||
(logOptionsStorageKey
|
||||
? parseInt(store.get(`${logOptionsStorageKey}.fieldSelector.width`) ?? DEFAULT_WIDTH, 10)
|
||||
: undefined) ?? DEFAULT_WIDTH;
|
||||
|
||||
return width < MIN_WIDTH ? MIN_WIDTH : width;
|
||||
}
|
||||
|
||||
export function getSidebarState(logOptionsStorageKey?: string): boolean | undefined {
|
||||
if (!logOptionsStorageKey) {
|
||||
return undefined;
|
||||
}
|
||||
const width = parseInt(store.get(`${logOptionsStorageKey}.fieldSelector.width`) ?? DEFAULT_WIDTH, 10);
|
||||
return width <= MIN_WIDTH * 2 ? false : true;
|
||||
}
|
||||
@@ -49,16 +49,42 @@ function getDisplayedFieldsForLanguages(logs: LogListModel[] | LogRowModel[], la
|
||||
);
|
||||
}
|
||||
|
||||
/***
|
||||
* Given a list of logs, identify the OTel language for each, use the language to match displayed fields
|
||||
* and return a list of fields to display based on the languages present in the logs.
|
||||
*/
|
||||
export function getDisplayedFieldsForLogs(logs: LogListModel[] | LogRowModel[]): string[] {
|
||||
return getDisplayedFieldsForLanguages(logs, identifyOTelLanguages(logs));
|
||||
}
|
||||
|
||||
// Languages not implemented.
|
||||
export function getDisplayFormatForLanguage(language: string) {
|
||||
function getDisplayFormatForLanguage(language: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getDefaultOTelDisplayFormat() {
|
||||
/***
|
||||
* Given a list of logs, return a list of suggested fields to display for the user.
|
||||
*/
|
||||
export function getSuggestedFieldsForLogs(logs: LogListModel[] | LogRowModel[]): string[] {
|
||||
const languages = identifyOTelLanguages(logs);
|
||||
if (!languages.length) {
|
||||
return [];
|
||||
}
|
||||
const fields = getSuggestedOTelDisplayFormat();
|
||||
|
||||
return fields.filter(
|
||||
(field) =>
|
||||
field === LOG_LINE_BODY_FIELD_NAME ||
|
||||
field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME ||
|
||||
logs.some((log) => log.labels[field] !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function getSuggestedOTelDisplayFormat() {
|
||||
return ['scope_name', ...getDefaultOTelDisplayFormat()];
|
||||
}
|
||||
|
||||
function getDefaultOTelDisplayFormat() {
|
||||
return [
|
||||
'thread_name',
|
||||
'exception_type',
|
||||
|
||||
@@ -389,6 +389,7 @@ export const LogLineContext = memo(
|
||||
<LogList
|
||||
app={CoreApp.Unknown}
|
||||
containerElement={containerRef.current}
|
||||
dataFrames={[]}
|
||||
dedupStrategy={LogsDedupStrategy.none}
|
||||
detailsMode="inline"
|
||||
displayedFields={displayedFields}
|
||||
@@ -397,6 +398,7 @@ export const LogLineContext = memo(
|
||||
infiniteScrollMode="unlimited"
|
||||
loadMore={handleLoadMore}
|
||||
logLineMenuCustomItems={logLineMenuCustomItems}
|
||||
logOptionsStorageKey={logOptionsStorageKey}
|
||||
logs={allLogs}
|
||||
loading={aboveState === LoadingState.Loading || belowState === LoadingState.Loading}
|
||||
permalinkedLogId={log.uid}
|
||||
@@ -404,6 +406,7 @@ export const LogLineContext = memo(
|
||||
onClickHideField={onClickHideField}
|
||||
onClickShowField={onClickShowField}
|
||||
showControls
|
||||
showFieldSelector={false}
|
||||
showTime={logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.showTime`, true) : true}
|
||||
sortOrder={sortOrder}
|
||||
syntaxHighlighting={syntaxHighlighting}
|
||||
|
||||
@@ -3,18 +3,20 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import {
|
||||
CoreApp,
|
||||
FieldType,
|
||||
getDefaultTimeRange,
|
||||
LogLevel,
|
||||
LogRowModel,
|
||||
LogsDedupStrategy,
|
||||
LogsSortOrder,
|
||||
store,
|
||||
toDataFrame,
|
||||
} from '@grafana/data';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
|
||||
import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { createLogRow } from '../mocks/logRow';
|
||||
import { createLogLine, createLogRow } from '../mocks/logRow';
|
||||
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats';
|
||||
|
||||
import { LogList, Props } from './LogList';
|
||||
@@ -68,6 +70,7 @@ describe('LogList', () => {
|
||||
containerElement: document.createElement('div'),
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
displayedFields: [],
|
||||
dataFrames: [],
|
||||
enableLogDetails: true,
|
||||
logs,
|
||||
showControls: false,
|
||||
@@ -480,4 +483,99 @@ describe('LogList', () => {
|
||||
expect(reportInteraction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('Field selector', () => {
|
||||
const logs = [
|
||||
createLogLine({ uid: '1', entry: 'log 1', labels: { service: 'frontend', level: 'info' } }),
|
||||
createLogLine({ uid: '2', entry: 'log 2', labels: { service: 'backend', level: 'error' } }),
|
||||
];
|
||||
|
||||
const dataFrames = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'timestamp', type: FieldType.time, values: [1, 2] },
|
||||
{ name: 'body', type: FieldType.string, values: ['log 1', 'log 2'] },
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
values: [
|
||||
{ service: 'frontend', level: 'info' },
|
||||
{ service: 'backend', level: 'error' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const extraProps = {
|
||||
onClickShowField: jest.fn(),
|
||||
onClickHideField: jest.fn(),
|
||||
setDisplayedFields: jest.fn(),
|
||||
logs,
|
||||
dataFrames,
|
||||
};
|
||||
|
||||
test('Does not display the field selector if not enabled', async () => {
|
||||
render(<LogList {...defaultProps} {...extraProps} showFieldSelector={false} />);
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search fields by name')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Fields')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Displays the field selector if enabled', async () => {
|
||||
render(<LogList {...defaultProps} {...extraProps} showFieldSelector />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Search fields by name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fields')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Toggles displayed fields on and off', async () => {
|
||||
const { rerender } = render(<LogList {...defaultProps} {...extraProps} showFieldSelector />);
|
||||
|
||||
await screen.findByText('log 1');
|
||||
await screen.findByText('log 2');
|
||||
|
||||
await userEvent.click(screen.getByText('service'));
|
||||
|
||||
expect(extraProps.onClickShowField).toHaveBeenCalledWith('service');
|
||||
|
||||
rerender(<LogList {...defaultProps} displayedFields={['service']} {...extraProps} showFieldSelector />);
|
||||
|
||||
expect(screen.getByText('frontend')).toBeInTheDocument();
|
||||
expect(screen.getByText('backend')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('service'));
|
||||
|
||||
expect(extraProps.onClickHideField).toHaveBeenCalledWith('service');
|
||||
});
|
||||
|
||||
test('Applies OTel default displayed fields and suggested fields', () => {
|
||||
const originalState = config.featureToggles.otelLogsFormatting;
|
||||
config.featureToggles.otelLogsFormatting = true;
|
||||
|
||||
const logs = [
|
||||
createLogRow({
|
||||
uid: '1',
|
||||
entry: 'log message 1',
|
||||
labels: { service: 'frontend', level: 'info', [OTEL_PROBE_FIELD]: '1', scope_name: 'test' },
|
||||
}),
|
||||
];
|
||||
|
||||
render(<LogList {...defaultProps} {...extraProps} showFieldSelector logs={logs} />);
|
||||
|
||||
// Log line message
|
||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||
|
||||
// Label
|
||||
expect(screen.getByText('service')).toBeInTheDocument();
|
||||
|
||||
// Default displayed fields
|
||||
expect(screen.getByText('Log line')).toBeInTheDocument();
|
||||
expect(screen.getByText('OTel attributes')).toBeInTheDocument();
|
||||
|
||||
// Suggested field
|
||||
expect(screen.getByText('scope_name')).toBeInTheDocument();
|
||||
|
||||
config.featureToggles.otelLogsFormatting = originalState;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ import { ConfirmModal, Icon, PopoverContent, useStyles2, useTheme2 } from '@graf
|
||||
import { PopoverMenu } from 'app/features/explore/Logs/PopoverMenu';
|
||||
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
||||
|
||||
import { LogListFieldSelector } from '../fieldSelector/FieldSelector';
|
||||
|
||||
import { InfiniteScrollMode, InfiniteScroll, LoadMoreLogsType } from './InfiniteScroll';
|
||||
import { getGridTemplateColumns, LogLineTimestampResolution } from './LogLine';
|
||||
import { LogLineDetails, LogLineDetailsMode } from './LogLineDetails';
|
||||
@@ -40,6 +42,7 @@ export interface Props {
|
||||
app: CoreApp;
|
||||
containerElement: HTMLDivElement;
|
||||
dedupStrategy: LogsDedupStrategy;
|
||||
dataFrames?: DataFrame[];
|
||||
detailsMode?: LogLineDetailsMode;
|
||||
displayedFields: string[];
|
||||
enableLogDetails: boolean;
|
||||
@@ -78,6 +81,7 @@ export interface Props {
|
||||
prettifyJSON?: boolean;
|
||||
setDisplayedFields?: (displayedFields: string[]) => void;
|
||||
showControls: boolean;
|
||||
showFieldSelector?: boolean;
|
||||
/**
|
||||
* Experimental. When OTel logs are displayed, add an extra displayed field with relevant key-value pairs from labels and metadata
|
||||
* @alpha
|
||||
@@ -114,6 +118,7 @@ type LogListComponentProps = Omit<
|
||||
export const LogList = ({
|
||||
app,
|
||||
displayedFields,
|
||||
dataFrames,
|
||||
containerElement,
|
||||
logOptionsStorageKey,
|
||||
detailsMode,
|
||||
@@ -153,6 +158,7 @@ export const LogList = ({
|
||||
prettifyJSON = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.prettifyLogMessage`, true) : true,
|
||||
setDisplayedFields,
|
||||
showControls,
|
||||
showFieldSelector,
|
||||
showLogAttributes,
|
||||
showTime,
|
||||
showUniqueLabels,
|
||||
@@ -210,6 +216,7 @@ export const LogList = ({
|
||||
<LogListSearchContextProvider>
|
||||
<LogListComponent
|
||||
containerElement={containerElement}
|
||||
dataFrames={dataFrames}
|
||||
eventBus={eventBus}
|
||||
getFieldLinks={getFieldLinks}
|
||||
grammar={grammar}
|
||||
@@ -219,6 +226,7 @@ export const LogList = ({
|
||||
loadMore={loadMore}
|
||||
logs={logs}
|
||||
showControls={showControls}
|
||||
showFieldSelector={showFieldSelector}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
@@ -229,6 +237,7 @@ export const LogList = ({
|
||||
|
||||
const LogListComponent = ({
|
||||
containerElement,
|
||||
dataFrames,
|
||||
eventBus = new EventBusSrv(),
|
||||
getFieldLinks,
|
||||
grammar,
|
||||
@@ -238,6 +247,7 @@ const LogListComponent = ({
|
||||
loadMore,
|
||||
logs,
|
||||
showControls,
|
||||
showFieldSelector,
|
||||
timeRange,
|
||||
timeZone,
|
||||
}: LogListComponentProps) => {
|
||||
@@ -524,6 +534,9 @@ const LogListComponent = ({
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
{showFieldSelector && processedLogs.length > 0 && dataFrames && (
|
||||
<LogListFieldSelector containerElement={containerElement} dataFrames={dataFrames} logs={processedLogs} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import { config, getDataSourceSrv } from '@grafana/runtime';
|
||||
import { PopoverContent } from '@grafana/ui';
|
||||
|
||||
import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils';
|
||||
import { getSidebarState } from '../fieldSelector/FieldSelector';
|
||||
import { getDisplayedFieldsForLogs } from '../otel/formats';
|
||||
|
||||
import { LogLineTimestampResolution } from './LogLine';
|
||||
@@ -277,6 +278,7 @@ export const LogListContextProvider = ({
|
||||
dedupStrategy,
|
||||
fontSize,
|
||||
forceEscape: logListState.forceEscape,
|
||||
fieldSelectorOpen: getSidebarState(logOptionsStorageKey),
|
||||
showTime,
|
||||
showUniqueLabels,
|
||||
syntaxHighlighting,
|
||||
|
||||
@@ -149,6 +149,7 @@ export const LogsPanel = ({
|
||||
fieldConfig,
|
||||
options: {
|
||||
showControls,
|
||||
showFieldSelector,
|
||||
controlsStorageKey,
|
||||
showLabels,
|
||||
showTime,
|
||||
@@ -581,6 +582,7 @@ export const LogsPanel = ({
|
||||
<LogList
|
||||
app={isCoreApp(app) ? app : CoreApp.Dashboard}
|
||||
containerElement={scrollElement}
|
||||
dataFrames={panelData.series}
|
||||
dedupStrategy={dedupStrategy}
|
||||
detailsMode={detailsMode}
|
||||
displayedFields={displayedFields}
|
||||
@@ -617,6 +619,7 @@ export const LogsPanel = ({
|
||||
prettifyJSON={prettifyLogMessage}
|
||||
setDisplayedFields={setDisplayedFieldsFn}
|
||||
showControls={Boolean(showControls)}
|
||||
showFieldSelector={showFieldSelector}
|
||||
showLogAttributes={showLogAttributes}
|
||||
showTime={showTime}
|
||||
showUniqueLabels={showLabels}
|
||||
|
||||
@@ -28,6 +28,7 @@ composableKinds: PanelCfg: {
|
||||
Options: {
|
||||
showLabels: bool
|
||||
showCommonLabels: bool
|
||||
showFieldSelector?: bool
|
||||
showTime: bool
|
||||
showLogContextToggle: bool
|
||||
showControls?: bool
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface Options {
|
||||
setDisplayedFields?: unknown;
|
||||
showCommonLabels: boolean;
|
||||
showControls?: boolean;
|
||||
showFieldSelector?: boolean;
|
||||
showLabels: boolean;
|
||||
showLogAttributes?: boolean;
|
||||
showLogContextToggle: boolean;
|
||||
|
||||
@@ -7156,9 +7156,6 @@
|
||||
"title-failed-sample-query": "Failed to load logs sample for this query",
|
||||
"tooltip": "Show log lines that contributed to visualized metrics"
|
||||
},
|
||||
"logs-table-available-fields": {
|
||||
"title-label-percentage": "{{labelName}} appears in {{percentage}}% of log lines"
|
||||
},
|
||||
"logs-table-empty-fields": {
|
||||
"no-fields": "No fields"
|
||||
},
|
||||
@@ -7167,10 +7164,6 @@
|
||||
"reset": "Reset",
|
||||
"selected-fields": "Selected fields"
|
||||
},
|
||||
"logs-table-nav-field": {
|
||||
"aria-label-drag-and-drop-icon": "Drag and drop icon",
|
||||
"title-drag-and-drop-to-reorder": "Drag and drop to reorder"
|
||||
},
|
||||
"logs-table-wrap": {
|
||||
"aria-label-select-query-by-name": "Select query by name",
|
||||
"label-select-query": "Select query",
|
||||
@@ -9587,6 +9580,15 @@
|
||||
"description-enable-logs-highlighting": "Use a predefined coloring scheme to highlight relevant parts of the log lines",
|
||||
"description-show-controls": "Display controls to jump to the last or first log line, and filters by log level",
|
||||
"description-show-log-attributes": "Experimental. When OTel logs are displayed, add an extra displayed field with relevant key-value pairs from labels and metadata.",
|
||||
"field-selector": {
|
||||
"aria-label-drag-and-drop-icon": "Drag and drop icon",
|
||||
"clear-button": "Clear",
|
||||
"collapse": "Collapse sidebar",
|
||||
"expand": "Expand sidebar",
|
||||
"label-title": "{{fieldName}} appears in {{percentage}}% of log lines",
|
||||
"placeholder-search-fields-by-name": "Search fields by name",
|
||||
"title-drag-and-drop-to-reorder": "Drag and drop to reorder"
|
||||
},
|
||||
"fields": {
|
||||
"type": {
|
||||
"loki": {
|
||||
|
||||
Reference in New Issue
Block a user