Logs Panel: Table UI - Reordering table columns via drag-and-drop (#79536)

* Implement react-beautiful-dnd to facilitate column reordering
* Refactoring field display components
* Refactoring internal types to better align with Grafana style guide

---------

Co-authored-by: Sven Grossmann <sven.grossmann@grafana.com>
This commit is contained in:
Galen Kistler 2024-01-16 14:27:29 -06:00 committed by GitHub
parent 581936a442
commit 7b8db643a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 555 additions and 197 deletions

View File

@ -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<ComponentProps<typeof LogsTable>>,
],
length: 3,
};
const logsFrame = parseLogsFrame(testDataFrame);
return (
<LogsTable
logsFrame={logsFrame}
height={400}
columnsWithMeta={{}}
columnsWithMeta={{
Time: { active: true, percentOfLinesWithLabel: 3, index: 0 },
line: { active: true, percentOfLinesWithLabel: 3, index: 1 },
}}
logsSortOrder={LogsSortOrder.Descending}
splitOpen={() => 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 },
},
});

View File

@ -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<string, fieldNameMeta>;
columnsWithMeta: Record<string, FieldNameMeta>;
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<DataFrame | undefined>(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<string, fieldNameMeta>) {
function buildLabelFilters(columnsWithMeta: Record<string, FieldNameMeta>) {
// Create object of label filters to include columns selected by the user
let labelFilters: Record<string, true> = {};
let labelFilters: Record<string, number> = {};
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<string, true>) {
function getLabelFiltersTransform(labelFilters: Record<string, number>) {
let labelFiltersInclude: Record<string, boolean> = {};
for (const key in labelFilters) {
labelFiltersInclude[key] = true;
}
if (Object.keys(labelFilters).length > 0) {
return {
id: 'organize',
options: {
includeByName: labelFilters,
indexByName: labelFilters,
includeByName: labelFiltersInclude,
},
};
}

View File

@ -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<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 />;
};

View File

@ -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<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) => 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={`${labelName} appears in ${labels[labelName]?.percentOfLinesWithLabel}% of log lines`}
>
<LogsTableNavField
showCount={true}
label={labelName}
onChange={() => toggleColumn(labelName)}
labels={labels}
/>
</div>
))}
</div>
);
}
return <LogsTableEmptyFields />;
};

View File

@ -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 <div className={styles.empty}>No fields</div>;
}

View File

@ -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<string, fieldNameMeta> | undefined;
columnsWithMeta: Record<string, fieldNameMeta>;
filteredColumnsWithMeta: Record<string, FieldNameMeta> | undefined;
columnsWithMeta: Record<string, FieldNameMeta>;
clear: () => void;
reorderColumn: (oldIndex: number, newIndex: number) => void;
}) => {
const theme = useTheme2();
const styles = getStyles(theme);
@ -56,14 +64,16 @@ export const LogsTableMultiSelect = (props: {
Reset
</button>
</div>
<LogsTableNavColumn
<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}>Fields</div>
<LogsTableNavColumn
<LogsTableAvailableFields
toggleColumn={props.toggleColumn}
labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta}
valueFilter={(value) => !props.columnsWithMeta[value]?.active}

View File

@ -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<string, fieldNameMeta>) {
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<string, fieldNameMeta>;
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 (
<div className={styles.columnWrapper}>
{labelKeys.sort(sortLabels(labels)).map((labelName) => (
<div
title={`${labelName} appears in ${labels[labelName]?.percentOfLinesWithLabel}% of log lines`}
className={styles.wrap}
key={labelName}
>
<Checkbox
className={styles.checkboxLabel}
label={labelName}
onChange={() => toggleColumn(labelName)}
checked={labels[labelName]?.active ?? false}
/>
<button className={styles.labelCount} onClick={() => toggleColumn(labelName)}>
{labels[labelName]?.percentOfLinesWithLabel}%
</button>
</div>
))}
</div>
);
}
return <div className={styles.empty}>No fields</div>;
};

View File

@ -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<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="Drag and drop icon"
title="Drag and drop to reorder"
name="draggabledots"
size="lg"
className={styles.dragIcon}
/>
)}
</>
);
}
return undefined;
}

View File

@ -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<fieldName, fieldNameMeta>;
export type FieldNameMeta = (InactiveFieldMeta | ActiveFieldMeta) & GenericMeta;
type FieldName = string;
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 [columnsWithMeta, setColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined);
// Filtered copy of columnsWithMeta that only includes matching results
const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState<fieldNameMetaStore | undefined>(undefined);
const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined);
const [searchValue, setSearchValue] = useState<string>('');
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<fieldName, fieldNameMeta>();
const labelCardinality = new Map<FieldName, FieldNameMeta>();
// 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<number, string> = 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) {
<section className={styles.sidebar}>
<LogsColumnSearch value={searchValue} onChange={onSearchInputChange} />
<LogsTableMultiSelect
reorderColumn={reorderColumn}
toggleColumn={toggleColumn}
filteredColumnsWithMeta={filteredColumnsWithMeta}
columnsWithMeta={columnsWithMeta}
@ -393,6 +516,7 @@ export function LogsTableWrap(props: Props) {
/>
</section>
<LogsTable
logsFrame={logsFrame}
onClickFilterLabel={props.onClickFilterLabel}
onClickFilterOutLabel={props.onClickFilterOutLabel}
logsSortOrder={props.logsSortOrder}

View File

@ -55,6 +55,13 @@ function getLogsTableTransformations(panelType: string, options: AddPanelToDashb
transformations.push({
id: 'organize',
options: {
indexByName: Object.values(options.panelState.logs.columns).reduce(
(acc: Record<string, number>, value: string, idx) => ({
...acc,
[value]: idx,
}),
{}
),
includeByName: Object.values(options.panelState.logs.columns).reduce(
(acc: Record<string, boolean>, value: string) => ({
...acc,