From 9e0fe94e8d406854b73e305d528ebcc050f780fa Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Wed, 22 Oct 2025 15:45:58 +0200 Subject: [PATCH] Fields Selector: Add component and integrate with Logs and Logs table visualization (#112534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .../logs/panelcfg/x/LogsPanelCfg_types.gen.ts | 1 + public/app/features/explore/Logs/Logs.tsx | 3 +- .../explore/Logs/LogsTableActiveFields.tsx | 108 ----- .../explore/Logs/LogsTableAvailableFields.tsx | 70 --- .../explore/Logs/LogsTableMultiSelect.tsx | 86 ---- .../explore/Logs/LogsTableNavField.tsx | 85 ---- .../explore/Logs/LogsTableWrap.test.tsx | 3 +- .../features/explore/Logs/LogsTableWrap.tsx | 182 +++---- .../logs/components/ControlledLogsTable.tsx | 6 +- .../components/fieldSelector/ActiveFields.tsx | 144 ++++++ .../fieldSelector/AvailableFields.tsx | 52 ++ .../components/fieldSelector/EmptyFields.tsx} | 7 +- .../logs/components/fieldSelector/Field.tsx | 99 ++++ .../components/fieldSelector/FieldList.tsx | 74 +++ .../components/fieldSelector/FieldSearch.tsx | 61 +++ .../fieldSelector/FieldSelector.test.tsx | 298 ++++++++++++ .../fieldSelector/FieldSelector.tsx | 454 ++++++++++++++++++ .../features/logs/components/otel/formats.ts | 30 +- .../logs/components/panel/LogLineContext.tsx | 3 + .../logs/components/panel/LogList.test.tsx | 100 +++- .../logs/components/panel/LogList.tsx | 13 + .../logs/components/panel/LogListContext.tsx | 2 + public/app/plugins/panel/logs/LogsPanel.tsx | 3 + public/app/plugins/panel/logs/panelcfg.cue | 1 + public/app/plugins/panel/logs/panelcfg.gen.ts | 1 + public/locales/en-US/grafana.json | 16 +- 26 files changed, 1421 insertions(+), 481 deletions(-) delete mode 100644 public/app/features/explore/Logs/LogsTableActiveFields.tsx delete mode 100644 public/app/features/explore/Logs/LogsTableAvailableFields.tsx delete mode 100644 public/app/features/explore/Logs/LogsTableMultiSelect.tsx delete mode 100644 public/app/features/explore/Logs/LogsTableNavField.tsx create mode 100644 public/app/features/logs/components/fieldSelector/ActiveFields.tsx create mode 100644 public/app/features/logs/components/fieldSelector/AvailableFields.tsx rename public/app/features/{explore/Logs/LogsTableEmptyFields.tsx => logs/components/fieldSelector/EmptyFields.tsx} (76%) create mode 100644 public/app/features/logs/components/fieldSelector/Field.tsx create mode 100644 public/app/features/logs/components/fieldSelector/FieldList.tsx create mode 100644 public/app/features/logs/components/fieldSelector/FieldSearch.tsx create mode 100644 public/app/features/logs/components/fieldSelector/FieldSelector.test.tsx create mode 100644 public/app/features/logs/components/fieldSelector/FieldSelector.tsx diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index 90b4c7194f1..9ea5e96bad3 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -40,6 +40,7 @@ export interface Options { setDisplayedFields?: unknown; showCommonLabels: boolean; showControls?: boolean; + showFieldSelector?: boolean; showLabels: boolean; showLogAttributes?: boolean; showLogContextToggle: boolean; diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 1049a09567a..f4e0c956e4d 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -995,7 +995,6 @@ const UnthemedLogs: React.FunctionComponent = (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) => { 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) => { pinnedLogs={pinnedLogs} setDisplayedFields={setDisplayedFields} showControls + showFieldSelector showTime={showTime} sortOrder={logsSortOrder} timeRange={props.range} diff --git a/public/app/features/explore/Logs/LogsTableActiveFields.tsx b/public/app/features/explore/Logs/LogsTableActiveFields.tsx deleted file mode 100644 index c228c7aaf7b..00000000000 --- a/public/app/features/explore/Logs/LogsTableActiveFields.tsx +++ /dev/null @@ -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) { - 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; - 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 ( - - - {(provided) => ( -
- {labelKeys.sort(sortLabels(labels)).map((labelName, index) => ( - - {(provided: DraggableProvided, snapshot) => ( -
- toggleColumn(labelName)} - labels={labels} - draggable={true} - /> -
- )} -
- ))} - {provided.placeholder} -
- )} -
-
- ); - } - - return ; -}; diff --git a/public/app/features/explore/Logs/LogsTableAvailableFields.tsx b/public/app/features/explore/Logs/LogsTableAvailableFields.tsx deleted file mode 100644 index 2fff4a27fff..00000000000 --- a/public/app/features/explore/Logs/LogsTableAvailableFields.tsx +++ /dev/null @@ -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) { - 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; - 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 ( -
- {labelKeys.sort(sortLabels(labels)).map((labelName, index) => ( -
- toggleColumn(labelName)} - labels={labels} - /> -
- ))} -
- ); - } - - return ; -}; diff --git a/public/app/features/explore/Logs/LogsTableMultiSelect.tsx b/public/app/features/explore/Logs/LogsTableMultiSelect.tsx deleted file mode 100644 index 8a1b9c39457..00000000000 --- a/public/app/features/explore/Logs/LogsTableMultiSelect.tsx +++ /dev/null @@ -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 | undefined; - columnsWithMeta: Record; - clear: () => void; - reorderColumn: (oldIndex: number, newIndex: number) => void; -}) => { - const theme = useTheme2(); - const styles = getStyles(theme); - - return ( -
- {/* Sidebar columns */} - <> -
- Selected fields - -
- props.columnsWithMeta[value]?.active ?? false} - id={'selected-fields'} - /> - -
- Fields -
- !props.columnsWithMeta[value]?.active} - /> - -
- ); -}; diff --git a/public/app/features/explore/Logs/LogsTableNavField.tsx b/public/app/features/explore/Logs/LogsTableNavField.tsx deleted file mode 100644 index ec53c667051..00000000000 --- a/public/app/features/explore/Logs/LogsTableNavField.tsx +++ /dev/null @@ -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; - draggable?: boolean; - showCount?: boolean; -}): React.JSX.Element | undefined { - const theme = useTheme2(); - - const styles = getStyles(theme); - - if (props.labels[props.label]) { - return ( - <> -
- - {props.showCount && ( - - )} -
- {props.draggable && ( - - )} - - ); - } - return undefined; -} diff --git a/public/app/features/explore/Logs/LogsTableWrap.test.tsx b/public/app/features/explore/Logs/LogsTableWrap.test.tsx index b14690a32a9..f5bd3498b87 100644 --- a/public/app/features/explore/Logs/LogsTableWrap.test.tsx +++ b/public/app/features/explore/Logs/LogsTableWrap.test.tsx @@ -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 ); diff --git a/public/app/features/explore/Logs/LogsTableWrap.tsx b/public/app/features/explore/Logs/LogsTableWrap.tsx index 54d390713c9..67829081b31 100644 --- a/public/app/features/explore/Logs/LogsTableWrap.tsx +++ b/public/app/features/explore/Logs/LogsTableWrap.tsx @@ -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; +export type FieldNameMetaStore = Record; 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(undefined); + const dragStyles = useStyles2(getDragStyles); // Filtered copy of columnsWithMeta that only includes matching results const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState(undefined); - const [searchValue, setSearchValue] = useState(''); 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) => { - 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) => { 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} > -
- - -
+ - +
+
+ +
+
); @@ -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, }), }; } diff --git a/public/app/features/logs/components/ControlledLogsTable.tsx b/public/app/features/logs/components/ControlledLogsTable.tsx index 79ac17da6c2..8b403d0368b 100644 --- a/public/app/features/logs/components/ControlledLogsTable.tsx +++ b/public/app/features/logs/components/ControlledLogsTable.tsx @@ -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(': Missing required props.'); @@ -56,7 +55,6 @@ export const ControlledLogsTable = ({ onClickFilterLabel={onClickFilterLabel} onClickFilterOutLabel={onClickFilterOutLabel} panelState={panelState} - theme={theme} updatePanelState={updatePanelState} datasourceType={datasourceType} /> diff --git a/public/app/features/logs/components/fieldSelector/ActiveFields.tsx b/public/app/features/logs/components/fieldSelector/ActiveFields.tsx new file mode 100644 index 00000000000..597f1715317 --- /dev/null +++ b/public/app/features/logs/components/fieldSelector/ActiveFields.tsx @@ -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 ( + <> +
+ Selected fields + +
+ + + {(provided) => ( +
+ {active.map((field, index) => ( + + {(provided: DraggableProvided, snapshot) => ( +
+ +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+ + ); + } + + 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), + }), + }; +} diff --git a/public/app/features/logs/components/fieldSelector/AvailableFields.tsx b/public/app/features/logs/components/fieldSelector/AvailableFields.tsx new file mode 100644 index 00000000000..bef8933b352 --- /dev/null +++ b/public/app/features/logs/components/fieldSelector/AvailableFields.tsx @@ -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 ( +
+ {availableFields.map((field) => ( +
+ +
+ ))} +
+ ); + } + + return ; +}; + +const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); + +function sortFields(a: FieldWithStats, b: FieldWithStats) { + return collator.compare(a.name, b.name); +} diff --git a/public/app/features/explore/Logs/LogsTableEmptyFields.tsx b/public/app/features/logs/components/fieldSelector/EmptyFields.tsx similarity index 76% rename from public/app/features/explore/Logs/LogsTableEmptyFields.tsx rename to public/app/features/logs/components/fieldSelector/EmptyFields.tsx index de42b1903e5..484d41b1dbe 100644 --- a/public/app/features/explore/Logs/LogsTableEmptyFields.tsx +++ b/public/app/features/logs/components/fieldSelector/EmptyFields.tsx @@ -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 (
No fields diff --git a/public/app/features/logs/components/fieldSelector/Field.tsx b/public/app/features/logs/components/fieldSelector/Field.tsx new file mode 100644 index 00000000000..c1ae562ad1f --- /dev/null +++ b/public/app/features/logs/components/fieldSelector/Field.tsx @@ -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 ( + <> +
+ + {showCount && ( + + )} +
+ {draggable && ( + + )} + + ); +} + +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%', + }, + }), + }; +} diff --git a/public/app/features/logs/components/fieldSelector/FieldList.tsx b/public/app/features/logs/components/fieldSelector/FieldList.tsx new file mode 100644 index 00000000000..16c054f3f48 --- /dev/null +++ b/public/app/features/logs/components/fieldSelector/FieldList.tsx @@ -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 ( +
+ {/* Sidebar columns */} + <> + + +
+ Fields +
+ + +
+ ); +}; + +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), + }), + }; +} diff --git a/public/app/features/logs/components/fieldSelector/FieldSearch.tsx b/public/app/features/logs/components/fieldSelector/FieldSearch.tsx new file mode 100644 index 00000000000..76c3697b7d8 --- /dev/null +++ b/public/app/features/logs/components/fieldSelector/FieldSearch.tsx @@ -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): void; + value: string; +} + +export function FieldSearch({ collapse, onChange, value }: Props) { + const styles = useStyles2(getStyles); + return ( + <> + + + onChange()} + /> + ) : undefined + } + /> + + + ); +} + +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), + }), + }; +} diff --git a/public/app/features/logs/components/fieldSelector/FieldSelector.test.tsx b/public/app/features/logs/components/fieldSelector/FieldSelector.test.tsx new file mode 100644 index 00000000000..e1ead1a005a --- /dev/null +++ b/public/app/features/logs/components/fieldSelector/FieldSelector.test.tsx @@ -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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + expect(screen.getByPlaceholderText('Search fields by name')).toBeInTheDocument(); + }); + + test('should call clear when reset button is clicked', async () => { + render( + + + + ); + + const resetButton = screen.getByText('Reset'); + await userEvent.click(resetButton); + + expect(setDisplayedFields).toHaveBeenCalledWith([]); + }); + + test('should not render the selected fields without selected fields', () => { + render( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + await userEvent.click(screen.getByLabelText('Collapse sidebar')); + + expect(setSidebarWidth).toHaveBeenCalledWith(MIN_WIDTH); + expect(storeSpy).toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/logs/components/fieldSelector/FieldSelector.tsx b/public/app/features/logs/components/fieldSelector/FieldSelector.tsx new file mode 100644 index 00000000000..44b1ff0f85c --- /dev/null +++ b/public/app/features/logs/components/fieldSelector/FieldSelector.tsx @@ -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 ( + + {sidebarWidth > MIN_WIDTH * 2 ? ( + + ) : ( +
+ +
+ )} +
+ ); +}; + +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 ? ( + + ) : ( +
+ +
+ ); +}; + +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(''); + const styles = useStyles2(getStyles); + + const onSearchInputChange = useCallback((e?: React.FormEvent) => { + 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 ( +
+ + +
+ ); +}; + +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(); + 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; +} diff --git a/public/app/features/logs/components/otel/formats.ts b/public/app/features/logs/components/otel/formats.ts index 6f870325231..c767a69a89e 100644 --- a/public/app/features/logs/components/otel/formats.ts +++ b/public/app/features/logs/components/otel/formats.ts @@ -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', diff --git a/public/app/features/logs/components/panel/LogLineContext.tsx b/public/app/features/logs/components/panel/LogLineContext.tsx index 418bf8f84f0..59a34bb9cae 100644 --- a/public/app/features/logs/components/panel/LogLineContext.tsx +++ b/public/app/features/logs/components/panel/LogLineContext.tsx @@ -389,6 +389,7 @@ export const LogLineContext = memo( { 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(); + + expect(screen.queryByPlaceholderText('Search fields by name')).not.toBeInTheDocument(); + expect(screen.queryByText('Fields')).not.toBeInTheDocument(); + }); + + test('Displays the field selector if enabled', async () => { + render(); + + expect(screen.getByPlaceholderText('Search fields by name')).toBeInTheDocument(); + expect(screen.getByText('Fields')).toBeInTheDocument(); + }); + + test('Toggles displayed fields on and off', async () => { + const { rerender } = render(); + + await screen.findByText('log 1'); + await screen.findByText('log 2'); + + await userEvent.click(screen.getByText('service')); + + expect(extraProps.onClickShowField).toHaveBeenCalledWith('service'); + + rerender(); + + 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(); + + // 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; + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 055539ed985..27a9379c8b8 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -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 = ({ @@ -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 = ({ )}
+ {showFieldSelector && processedLogs.length > 0 && dataFrames && ( + + )} ); }; diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index bb30028523e..efa4dfcda8a 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -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, diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index d86e1d020be..c2659859b3e 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -149,6 +149,7 @@ export const LogsPanel = ({ fieldConfig, options: { showControls, + showFieldSelector, controlsStorageKey, showLabels, showTime, @@ -581,6 +582,7 @@ export const LogsPanel = ({