diff --git a/public/app/features/explore/Logs/LogsTable.test.tsx b/public/app/features/explore/Logs/LogsTable.test.tsx index 06ba89a83ea..7eb047add6f 100644 --- a/public/app/features/explore/Logs/LogsTable.test.tsx +++ b/public/app/features/explore/Logs/LogsTable.test.tsx @@ -6,6 +6,8 @@ import { organizeFieldsTransformer } from '@grafana/data/src/transformations/tra import { config } from '@grafana/runtime'; import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields'; +import { parseLogsFrame } from '../../logs/logsFrame'; + import { LogsTable } from './LogsTable'; import { getMockElasticFrame, getMockLokiFrame, getMockLokiFrameDataPlane } from './utils/testMocks.test'; @@ -52,10 +54,15 @@ const getComponent = (partialProps?: Partial>, ], length: 3, }; + const logsFrame = parseLogsFrame(testDataFrame); return ( undefined} timeZone={'utc'} @@ -123,10 +130,10 @@ describe('LogsTable', () => { setup({ dataFrame: getMockElasticFrame(), columnsWithMeta: { - counter: { active: true, percentOfLinesWithLabel: 3 }, - level: { active: true, percentOfLinesWithLabel: 3 }, - line: { active: true, percentOfLinesWithLabel: 3 }, - '@timestamp': { active: true, percentOfLinesWithLabel: 3 }, + level: { active: true, percentOfLinesWithLabel: 3, index: 3 }, + counter: { active: true, percentOfLinesWithLabel: 3, index: 2 }, + line: { active: true, percentOfLinesWithLabel: 3, index: 1 }, + '@timestamp': { active: true, percentOfLinesWithLabel: 3, index: 0 }, }, }); @@ -142,9 +149,9 @@ describe('LogsTable', () => { it('should render extracted labels as columns (loki)', async () => { setup({ columnsWithMeta: { - foo: { active: true, percentOfLinesWithLabel: 3 }, - Time: { active: true, percentOfLinesWithLabel: 3 }, - line: { active: true, percentOfLinesWithLabel: 3 }, + Time: { active: true, percentOfLinesWithLabel: 3, index: 0 }, + line: { active: true, percentOfLinesWithLabel: 3, index: 1 }, + foo: { active: true, percentOfLinesWithLabel: 3, index: 2 }, }, }); @@ -194,7 +201,15 @@ describe('LogsTable', () => { }); it('should render 4 table rows', async () => { - setup(undefined, getMockLokiFrameDataPlane()); + setup( + { + columnsWithMeta: { + timestamp: { active: true, percentOfLinesWithLabel: 3, index: 0 }, + body: { active: true, percentOfLinesWithLabel: 3, index: 1 }, + }, + }, + getMockLokiFrameDataPlane() + ); await waitFor(() => { const rows = screen.getAllByRole('row'); @@ -208,7 +223,7 @@ describe('LogsTable', () => { getComponent( { columnsWithMeta: { - traceID: { active: true, percentOfLinesWithLabel: 3 }, + traceID: { active: true, percentOfLinesWithLabel: 3, index: 0 }, }, }, getMockLokiFrameDataPlane() @@ -223,7 +238,15 @@ describe('LogsTable', () => { }); it('should not render `labels`', async () => { - setup(undefined, getMockLokiFrameDataPlane()); + setup( + { + columnsWithMeta: { + timestamp: { active: true, percentOfLinesWithLabel: 100, index: 0 }, + body: { active: true, percentOfLinesWithLabel: 100, index: 1 }, + }, + }, + getMockLokiFrameDataPlane() + ); await waitFor(() => { const columns = screen.queryAllByRole('columnheader', { name: 'labels' }); @@ -233,7 +256,15 @@ describe('LogsTable', () => { }); it('should not render `tsNs`', async () => { - setup(undefined, getMockLokiFrameDataPlane()); + setup( + { + columnsWithMeta: { + timestamp: { active: true, percentOfLinesWithLabel: 100, index: 0 }, + body: { active: true, percentOfLinesWithLabel: 100, index: 1 }, + }, + }, + getMockLokiFrameDataPlane() + ); await waitFor(() => { const columns = screen.queryAllByRole('columnheader', { name: 'tsNs' }); @@ -245,9 +276,9 @@ describe('LogsTable', () => { it('should render extracted labels as columns (loki dataplane)', async () => { setup({ columnsWithMeta: { - foo: { active: true, percentOfLinesWithLabel: 3 }, - line: { active: true, percentOfLinesWithLabel: 3 }, - Time: { active: true, percentOfLinesWithLabel: 3 }, + foo: { active: true, percentOfLinesWithLabel: 3, index: 2 }, + line: { active: true, percentOfLinesWithLabel: 3, index: 1 }, + Time: { active: true, percentOfLinesWithLabel: 3, index: 0 }, }, }); diff --git a/public/app/features/explore/Logs/LogsTable.tsx b/public/app/features/explore/Logs/LogsTable.tsx index 48719cce8ce..0d7a0c85d71 100644 --- a/public/app/features/explore/Logs/LogsTable.tsx +++ b/public/app/features/explore/Logs/LogsTable.tsx @@ -19,11 +19,11 @@ import { import { config } from '@grafana/runtime'; import { AdHocFilterItem, Table } from '@grafana/ui'; import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/src/components/Table/types'; -import { LogsFrame, parseLogsFrame } from 'app/features/logs/logsFrame'; +import { LogsFrame } from 'app/features/logs/logsFrame'; import { getFieldLinksForExplore } from '../utils/links'; -import { fieldNameMeta } from './LogsTableWrap'; +import { FieldNameMeta } from './LogsTableWrap'; interface Props { dataFrame: DataFrame; @@ -32,24 +32,23 @@ interface Props { splitOpen: SplitOpen; range: TimeRange; logsSortOrder: LogsSortOrder; - columnsWithMeta: Record; + columnsWithMeta: Record; height: number; onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; + logsFrame: LogsFrame | null; } export function LogsTable(props: Props) { - const { timeZone, splitOpen, range, logsSortOrder, width, dataFrame, columnsWithMeta } = props; + const { timeZone, splitOpen, range, logsSortOrder, width, dataFrame, columnsWithMeta, logsFrame } = props; const [tableFrame, setTableFrame] = useState(undefined); + const timeIndex = logsFrame?.timeField.index; const prepareTableFrame = useCallback( (frame: DataFrame): DataFrame => { if (!frame.length) { return frame; } - // Parse the dataframe to a logFrame - const logsFrame = parseLogsFrame(frame); - const timeIndex = logsFrame?.timeField.index; const sortedFrame = sortDataFrame(frame, timeIndex, logsSortOrder === LogsSortOrder.Descending); @@ -84,21 +83,18 @@ export function LogsTable(props: Props) { ...field.config.custom, }, // This sets the individual field value as filterable - filterable: isFieldFilterable(field, logsFrame ?? undefined), + filterable: isFieldFilterable(field, logsFrame?.bodyField.name ?? '', logsFrame?.timeField.name ?? ''), }; } return frameWithOverrides; }, - [logsSortOrder, timeZone, splitOpen, range] + [logsSortOrder, timeZone, splitOpen, range, logsFrame?.bodyField.name, logsFrame?.timeField.name, timeIndex] ); useEffect(() => { const prepare = async () => { - // Parse the dataframe to a logFrame - const logsFrame = dataFrame ? parseLogsFrame(dataFrame) : undefined; - - if (!logsFrame) { + if (!logsFrame?.timeField.name || !logsFrame?.bodyField.name) { setTableFrame(undefined); return; } @@ -117,6 +113,10 @@ export function LogsTable(props: Props) { transformations.push({ id: 'organize', options: { + indexByName: { + [logsFrame.bodyField.name]: 0, + [logsFrame.timeField.name]: 1, + }, includeByName: { [logsFrame.bodyField.name]: true, [logsFrame.timeField.name]: true, @@ -134,7 +134,14 @@ export function LogsTable(props: Props) { } }; prepare(); - }, [columnsWithMeta, dataFrame, logsSortOrder, prepareTableFrame]); + }, [ + columnsWithMeta, + dataFrame, + logsSortOrder, + prepareTableFrame, + logsFrame?.bodyField.name, + logsFrame?.timeField.name, + ]); if (!tableFrame) { return null; @@ -166,14 +173,14 @@ export function LogsTable(props: Props) { ); } -const isFieldFilterable = (field: Field, logsFrame?: LogsFrame | undefined) => { - if (!logsFrame) { +const isFieldFilterable = (field: Field, bodyName: string, timeName: string) => { + if (!bodyName || !timeName) { return false; } - if (logsFrame.bodyField.name === field.name) { + if (bodyName === field.name) { return false; } - if (logsFrame.timeField.name === field.name) { + if (timeName === field.name) { return false; } if (field.config.links?.length) { @@ -211,24 +218,35 @@ function extractFields(dataFrame: DataFrame) { }); } -function buildLabelFilters(columnsWithMeta: Record) { +function buildLabelFilters(columnsWithMeta: Record) { // Create object of label filters to include columns selected by the user - let labelFilters: Record = {}; + let labelFilters: Record = {}; Object.keys(columnsWithMeta) .filter((key) => columnsWithMeta[key].active) .forEach((key) => { - labelFilters[key] = true; + const index = columnsWithMeta[key].index; + // Index should always be defined for any active column + if (index !== undefined) { + labelFilters[key] = index; + } }); return labelFilters; } -function getLabelFiltersTransform(labelFilters: Record) { +function getLabelFiltersTransform(labelFilters: Record) { + let labelFiltersInclude: Record = {}; + + for (const key in labelFilters) { + labelFiltersInclude[key] = true; + } + if (Object.keys(labelFilters).length > 0) { return { id: 'organize', options: { - includeByName: labelFilters, + indexByName: labelFilters, + includeByName: labelFiltersInclude, }, }; } diff --git a/public/app/features/explore/Logs/LogsTableActiveFields.tsx b/public/app/features/explore/Logs/LogsTableActiveFields.tsx new file mode 100644 index 00000000000..802c59e6f99 --- /dev/null +++ b/public/app/features/explore/Logs/LogsTableActiveFields.tsx @@ -0,0 +1,109 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; +import { DragDropContext, Draggable, DraggableProvided, Droppable, DropResult } from 'react-beautiful-dnd'; + +import { GrafanaTheme2 } from '@grafana/data/src'; +import { useTheme2 } from '@grafana/ui/src'; + +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 new file mode 100644 index 00000000000..8ab7d480508 --- /dev/null +++ b/public/app/features/explore/Logs/LogsTableAvailableFields.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { useTheme2 } from '@grafana/ui/src'; + +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) => 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/LogsTableEmptyFields.tsx b/public/app/features/explore/Logs/LogsTableEmptyFields.tsx new file mode 100644 index 00000000000..a4a2c05426b --- /dev/null +++ b/public/app/features/explore/Logs/LogsTableEmptyFields.tsx @@ -0,0 +1,21 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useTheme2 } from '@grafana/ui'; + +function getStyles(theme: GrafanaTheme2) { + return { + empty: css({ + marginBottom: theme.spacing(2), + marginLeft: theme.spacing(1.75), + fontSize: theme.typography.fontSize, + }), + }; +} + +export function LogsTableEmptyFields() { + const theme = useTheme2(); + const styles = getStyles(theme); + return
No fields
; +} diff --git a/public/app/features/explore/Logs/LogsTableMultiSelect.tsx b/public/app/features/explore/Logs/LogsTableMultiSelect.tsx index 77431c09803..31b2d7c0b40 100644 --- a/public/app/features/explore/Logs/LogsTableMultiSelect.tsx +++ b/public/app/features/explore/Logs/LogsTableMultiSelect.tsx @@ -4,14 +4,21 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data/src'; import { useTheme2 } from '@grafana/ui/src'; -import { LogsTableNavColumn } from './LogsTableNavColumn'; -import { fieldNameMeta } from './LogsTableWrap'; +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', @@ -39,9 +46,10 @@ function getStyles(theme: GrafanaTheme2) { export const LogsTableMultiSelect = (props: { toggleColumn: (columnName: string) => void; - filteredColumnsWithMeta: Record | undefined; - columnsWithMeta: Record; + filteredColumnsWithMeta: Record | undefined; + columnsWithMeta: Record; clear: () => void; + reorderColumn: (oldIndex: number, newIndex: number) => void; }) => { const theme = useTheme2(); const styles = getStyles(theme); @@ -56,14 +64,16 @@ export const LogsTableMultiSelect = (props: { Reset - props.columnsWithMeta[value]?.active ?? false} + id={'selected-fields'} />
Fields
- !props.columnsWithMeta[value]?.active} diff --git a/public/app/features/explore/Logs/LogsTableNavColumn.tsx b/public/app/features/explore/Logs/LogsTableNavColumn.tsx deleted file mode 100644 index 5dfa2e9fe25..00000000000 --- a/public/app/features/explore/Logs/LogsTableNavColumn.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { css } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data/src'; -import { Checkbox, useTheme2 } from '@grafana/ui/src'; - -import { fieldNameMeta } from './LogsTableWrap'; - -function getStyles(theme: GrafanaTheme2) { - return { - labelCount: css({ - marginLeft: theme.spacing(0.5), - marginRight: theme.spacing(0.5), - appearance: 'none', - background: 'none', - border: 'none', - fontSize: theme.typography.pxToRem(11), - }), - wrap: css({ - display: 'flex', - alignItems: 'center', - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1), - justifyContent: 'space-between', - }), - // Making the checkbox sticky and label scrollable for labels that are wider then the container - // However, the checkbox component does not support this, so we need to do some css hackery for now until the API of that component is updated. - checkboxLabel: css({ - '> :first-child': { - position: 'sticky', - left: 0, - bottom: 0, - top: 0, - }, - '> span': { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - display: 'block', - maxWidth: '100%', - }, - }), - columnWrapper: css({ - marginBottom: theme.spacing(1.5), - // need some space or the outline of the checkbox is cut off - paddingLeft: theme.spacing(0.5), - }), - empty: css({ - marginBottom: theme.spacing(2), - marginLeft: theme.spacing(1.75), - fontSize: theme.typography.fontSize, - }), - }; -} - -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]; - - 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 LogsTableNavColumn = (props: { - labels: Record; - valueFilter: (value: string) => boolean; - toggleColumn: (columnName: string) => void; -}): JSX.Element => { - const { labels, valueFilter, toggleColumn } = props; - const theme = useTheme2(); - const styles = getStyles(theme); - const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName)); - if (labelKeys.length) { - return ( -
- {labelKeys.sort(sortLabels(labels)).map((labelName) => ( -
- toggleColumn(labelName)} - checked={labels[labelName]?.active ?? false} - /> - -
- ))} -
- ); - } - - return
No fields
; -}; diff --git a/public/app/features/explore/Logs/LogsTableNavField.tsx b/public/app/features/explore/Logs/LogsTableNavField.tsx new file mode 100644 index 00000000000..d883ae8ed30 --- /dev/null +++ b/public/app/features/explore/Logs/LogsTableNavField.tsx @@ -0,0 +1,83 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +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.tsx b/public/app/features/explore/Logs/LogsTableWrap.tsx index 74a3e79b87d..9911a50e622 100644 --- a/public/app/features/explore/Logs/LogsTableWrap.tsx +++ b/public/app/features/explore/Logs/LogsTableWrap.tsx @@ -35,22 +35,34 @@ interface Props extends Themeable2 { datasourceType?: string; } -export type fieldNameMeta = { +type ActiveFieldMeta = { + active: false; + index: undefined; // if undefined the column is not selected +}; + +type InactiveFieldMeta = { + active: true; + index: number; // if undefined the column is not selected +}; + +type GenericMeta = { percentOfLinesWithLabel: number; - active: boolean | undefined; type?: 'BODY_FIELD' | 'TIME_FIELD'; }; -type fieldName = string; -type fieldNameMetaStore = Record; + +export type FieldNameMeta = (InactiveFieldMeta | ActiveFieldMeta) & GenericMeta; + +type FieldName = string; +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 [columnsWithMeta, setColumnsWithMeta] = useState(undefined); // Filtered copy of columnsWithMeta that only includes matching results - const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState(undefined); + const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState(undefined); const [searchValue, setSearchValue] = useState(''); const height = getLogsTableHeight(); @@ -62,12 +74,13 @@ export function LogsTableWrap(props: Props) { ); const getColumnsFromProps = useCallback( - (fieldNames: fieldNameMetaStore) => { + (fieldNames: FieldNameMetaStore) => { const previouslySelected = props.panelState?.columns; if (previouslySelected) { - Object.values(previouslySelected).forEach((key) => { + Object.values(previouslySelected).forEach((key, index) => { if (fieldNames[key]) { fieldNames[key].active = true; + fieldNames[key].index = index; } }); } @@ -153,10 +166,10 @@ export function LogsTableWrap(props: Props) { } // Use a map to dedupe labels and count their occurrences in the logs - const labelCardinality = new Map(); + const labelCardinality = new Map(); // What the label state will look like - let pendingLabelState: fieldNameMetaStore = {}; + let pendingLabelState: FieldNameMetaStore = {}; // If we have labels and log lines if (labels?.length && numberOfLogLines) { @@ -169,14 +182,23 @@ export function LogsTableWrap(props: Props) { if (labelCardinality.has(label)) { const value = labelCardinality.get(label); if (value) { - labelCardinality.set(label, { - percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1, - active: value?.active, - }); + if (value?.active) { + labelCardinality.set(label, { + percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1, + active: true, + index: value.index, + }); + } else { + labelCardinality.set(label, { + percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1, + active: false, + index: undefined, + }); + } } // Otherwise add it } else { - labelCardinality.set(label, { percentOfLinesWithLabel: 1, active: undefined }); + labelCardinality.set(label, { percentOfLinesWithLabel: 1, active: false, index: undefined }); } }); }); @@ -195,13 +217,27 @@ export function LogsTableWrap(props: Props) { // Normalize the other fields otherFields.forEach((field) => { - pendingLabelState[field.name] = { - percentOfLinesWithLabel: normalize( - field.values.filter((value) => value !== null && value !== undefined).length, - numberOfLogLines - ), - active: pendingLabelState[field.name]?.active, - }; + const isActive = pendingLabelState[field.name]?.active; + const index = pendingLabelState[field.name]?.index; + if (isActive && index !== undefined) { + pendingLabelState[field.name] = { + percentOfLinesWithLabel: normalize( + field.values.filter((value) => value !== null && value !== undefined).length, + numberOfLogLines + ), + active: true, + index: index, + }; + } else { + pendingLabelState[field.name] = { + percentOfLinesWithLabel: normalize( + field.values.filter((value) => value !== null && value !== undefined).length, + numberOfLogLines + ), + active: false, + index: undefined, + }; + } }); pendingLabelState = getColumnsFromProps(pendingLabelState); @@ -255,45 +291,64 @@ export function LogsTableWrap(props: Props) { const clearSelection = () => { const pendingLabelState = { ...columnsWithMeta }; + let index = 0; Object.keys(pendingLabelState).forEach((key) => { - pendingLabelState[key].active = !!pendingLabelState[key].type; + const isDefaultField = !!pendingLabelState[key].type; + // after reset the only active fields are the special time and body fields + pendingLabelState[key].active = isDefaultField; + // reset the index + pendingLabelState[key].index = isDefaultField ? index++ : undefined; }); setColumnsWithMeta(pendingLabelState); }; - // Toggle a column on or off when the user interacts with an element in the multi-select sidebar - const toggleColumn = (columnName: fieldName) => { - if (!columnsWithMeta || !(columnName in columnsWithMeta)) { - console.warn('failed to get column', columnsWithMeta); + const reorderColumn = (sourceIndex: number, destinationIndex: number) => { + if (sourceIndex === destinationIndex) { return; } - const pendingLabelState = { - ...columnsWithMeta, - [columnName]: { ...columnsWithMeta[columnName], active: !columnsWithMeta[columnName]?.active }, - }; + const pendingLabelState = { ...columnsWithMeta }; - // Analytics - columnFilterEvent(columnName); + 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; + }); // Set local state setColumnsWithMeta(pendingLabelState); - // If user is currently filtering, update filtered state - if (filteredColumnsWithMeta) { - const pendingFilteredLabelState = { - ...filteredColumnsWithMeta, - [columnName]: { ...filteredColumnsWithMeta[columnName], active: !filteredColumnsWithMeta[columnName]?.active }, - }; - setFilteredColumnsWithMeta(pendingFilteredLabelState); - } + // Sync the explore state + updateExploreState(pendingLabelState); + }; + + function updateExploreState(pendingLabelState: FieldNameMetaStore) { + // Get all active columns and sort by index + const newColumnsArray = Object.keys(pendingLabelState) + // Only include active filters + .filter((key) => pendingLabelState[key]?.active) + .sort((a, b) => { + const pa = pendingLabelState[a]; + const pb = pendingLabelState[b]; + if (pa.index !== undefined && pb.index !== undefined) { + return pa.index - pb.index; // sort by index + } + return 0; + }); const newColumns: Record = Object.assign( {}, // Get the keys of the object as an array - Object.keys(pendingLabelState) - // Only include active filters - .filter((key) => pendingLabelState[key]?.active) + newColumnsArray ); const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' }; @@ -308,12 +363,79 @@ export function LogsTableWrap(props: Props) { // Update url state updatePanelState(newPanelState); + } + + // Toggle a column on or off when the user interacts with an element in the multi-select sidebar + const toggleColumn = (columnName: FieldName) => { + if (!columnsWithMeta || !(columnName in columnsWithMeta)) { + console.warn('failed to get column', columnsWithMeta); + return; + } + + const length = Object.keys(columnsWithMeta).filter((c) => columnsWithMeta[c].active).length; + const isActive = !columnsWithMeta[columnName].active ? true : undefined; + + let pendingLabelState: FieldNameMetaStore; + if (isActive) { + pendingLabelState = { + ...columnsWithMeta, + [columnName]: { + ...columnsWithMeta[columnName], + active: isActive, + index: length, + }, + }; + } else { + pendingLabelState = { + ...columnsWithMeta, + [columnName]: { + ...columnsWithMeta[columnName], + active: false, + index: undefined, + }, + }; + } + + // Analytics + columnFilterEvent(columnName); + + // Set local state + setColumnsWithMeta(pendingLabelState); + + // If user is currently filtering, update filtered state + if (filteredColumnsWithMeta) { + const active = !filteredColumnsWithMeta[columnName]?.active; + let pendingFilteredLabelState: FieldNameMetaStore; + if (active) { + pendingFilteredLabelState = { + ...filteredColumnsWithMeta, + [columnName]: { + ...filteredColumnsWithMeta[columnName], + active: active, + index: length, + }, + }; + } else { + pendingFilteredLabelState = { + ...filteredColumnsWithMeta, + [columnName]: { + ...filteredColumnsWithMeta[columnName], + active: false, + index: undefined, + }, + }; + } + + setFilteredColumnsWithMeta(pendingFilteredLabelState); + } + + updateExploreState(pendingLabelState); }; // uFuzzy search dispatcher, adds any matches to the local state const dispatcher = (data: string[][]) => { const matches = data[0]; - let newColumnsWithMeta: fieldNameMetaStore = {}; + let newColumnsWithMeta: FieldNameMetaStore = {}; let numberOfResults = 0; matches.forEach((match) => { if (match in columnsWithMeta) { @@ -386,6 +508,7 @@ export function LogsTableWrap(props: Props) {
, value: string, idx) => ({ + ...acc, + [value]: idx, + }), + {} + ), includeByName: Object.values(options.panelState.logs.columns).reduce( (acc: Record, value: string) => ({ ...acc,