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:
Matias Chomicki
2025-10-22 13:45:58 +00:00
committed by GitHub
co-authored by Copilot
parent be1b18a3d6
commit 9e0fe94e8d
26 changed files with 1421 additions and 481 deletions
@@ -40,6 +40,7 @@ export interface Options {
setDisplayedFields?: unknown;
showCommonLabels: boolean;
showControls?: boolean;
showFieldSelector?: boolean;
showLabels: boolean;
showLogAttributes?: boolean;
showLogContextToggle: boolean;
+2 -1
View File
@@ -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);
}
@@ -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;
+9 -7
View File
@@ -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": {