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 { config } from '@grafana/runtime';
import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields'; import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields';
import { parseLogsFrame } from '../../logs/logsFrame';
import { LogsTable } from './LogsTable'; import { LogsTable } from './LogsTable';
import { getMockElasticFrame, getMockLokiFrame, getMockLokiFrameDataPlane } from './utils/testMocks.test'; import { getMockElasticFrame, getMockLokiFrame, getMockLokiFrameDataPlane } from './utils/testMocks.test';
@ -52,10 +54,15 @@ const getComponent = (partialProps?: Partial<ComponentProps<typeof LogsTable>>,
], ],
length: 3, length: 3,
}; };
const logsFrame = parseLogsFrame(testDataFrame);
return ( return (
<LogsTable <LogsTable
logsFrame={logsFrame}
height={400} height={400}
columnsWithMeta={{}} columnsWithMeta={{
Time: { active: true, percentOfLinesWithLabel: 3, index: 0 },
line: { active: true, percentOfLinesWithLabel: 3, index: 1 },
}}
logsSortOrder={LogsSortOrder.Descending} logsSortOrder={LogsSortOrder.Descending}
splitOpen={() => undefined} splitOpen={() => undefined}
timeZone={'utc'} timeZone={'utc'}
@ -123,10 +130,10 @@ describe('LogsTable', () => {
setup({ setup({
dataFrame: getMockElasticFrame(), dataFrame: getMockElasticFrame(),
columnsWithMeta: { columnsWithMeta: {
counter: { active: true, percentOfLinesWithLabel: 3 }, level: { active: true, percentOfLinesWithLabel: 3, index: 3 },
level: { active: true, percentOfLinesWithLabel: 3 }, counter: { active: true, percentOfLinesWithLabel: 3, index: 2 },
line: { active: true, percentOfLinesWithLabel: 3 }, line: { active: true, percentOfLinesWithLabel: 3, index: 1 },
'@timestamp': { active: true, percentOfLinesWithLabel: 3 }, '@timestamp': { active: true, percentOfLinesWithLabel: 3, index: 0 },
}, },
}); });
@ -142,9 +149,9 @@ describe('LogsTable', () => {
it('should render extracted labels as columns (loki)', async () => { it('should render extracted labels as columns (loki)', async () => {
setup({ setup({
columnsWithMeta: { columnsWithMeta: {
foo: { active: true, percentOfLinesWithLabel: 3 }, Time: { active: true, percentOfLinesWithLabel: 3, index: 0 },
Time: { active: true, percentOfLinesWithLabel: 3 }, line: { active: true, percentOfLinesWithLabel: 3, index: 1 },
line: { active: true, percentOfLinesWithLabel: 3 }, foo: { active: true, percentOfLinesWithLabel: 3, index: 2 },
}, },
}); });
@ -194,7 +201,15 @@ describe('LogsTable', () => {
}); });
it('should render 4 table rows', async () => { 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(() => { await waitFor(() => {
const rows = screen.getAllByRole('row'); const rows = screen.getAllByRole('row');
@ -208,7 +223,7 @@ describe('LogsTable', () => {
getComponent( getComponent(
{ {
columnsWithMeta: { columnsWithMeta: {
traceID: { active: true, percentOfLinesWithLabel: 3 }, traceID: { active: true, percentOfLinesWithLabel: 3, index: 0 },
}, },
}, },
getMockLokiFrameDataPlane() getMockLokiFrameDataPlane()
@ -223,7 +238,15 @@ describe('LogsTable', () => {
}); });
it('should not render `labels`', async () => { 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(() => { await waitFor(() => {
const columns = screen.queryAllByRole('columnheader', { name: 'labels' }); const columns = screen.queryAllByRole('columnheader', { name: 'labels' });
@ -233,7 +256,15 @@ describe('LogsTable', () => {
}); });
it('should not render `tsNs`', async () => { 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(() => { await waitFor(() => {
const columns = screen.queryAllByRole('columnheader', { name: 'tsNs' }); const columns = screen.queryAllByRole('columnheader', { name: 'tsNs' });
@ -245,9 +276,9 @@ describe('LogsTable', () => {
it('should render extracted labels as columns (loki dataplane)', async () => { it('should render extracted labels as columns (loki dataplane)', async () => {
setup({ setup({
columnsWithMeta: { columnsWithMeta: {
foo: { active: true, percentOfLinesWithLabel: 3 }, foo: { active: true, percentOfLinesWithLabel: 3, index: 2 },
line: { active: true, percentOfLinesWithLabel: 3 }, line: { active: true, percentOfLinesWithLabel: 3, index: 1 },
Time: { active: true, percentOfLinesWithLabel: 3 }, Time: { active: true, percentOfLinesWithLabel: 3, index: 0 },
}, },
}); });

View File

@ -19,11 +19,11 @@ import {
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { AdHocFilterItem, Table } from '@grafana/ui'; import { AdHocFilterItem, Table } from '@grafana/ui';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/src/components/Table/types'; 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 { getFieldLinksForExplore } from '../utils/links';
import { fieldNameMeta } from './LogsTableWrap'; import { FieldNameMeta } from './LogsTableWrap';
interface Props { interface Props {
dataFrame: DataFrame; dataFrame: DataFrame;
@ -32,24 +32,23 @@ interface Props {
splitOpen: SplitOpen; splitOpen: SplitOpen;
range: TimeRange; range: TimeRange;
logsSortOrder: LogsSortOrder; logsSortOrder: LogsSortOrder;
columnsWithMeta: Record<string, fieldNameMeta>; columnsWithMeta: Record<string, FieldNameMeta>;
height: number; height: number;
onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void;
onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void;
logsFrame: LogsFrame | null;
} }
export function LogsTable(props: Props) { 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 [tableFrame, setTableFrame] = useState<DataFrame | undefined>(undefined);
const timeIndex = logsFrame?.timeField.index;
const prepareTableFrame = useCallback( const prepareTableFrame = useCallback(
(frame: DataFrame): DataFrame => { (frame: DataFrame): DataFrame => {
if (!frame.length) { if (!frame.length) {
return frame; 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); const sortedFrame = sortDataFrame(frame, timeIndex, logsSortOrder === LogsSortOrder.Descending);
@ -84,21 +83,18 @@ export function LogsTable(props: Props) {
...field.config.custom, ...field.config.custom,
}, },
// This sets the individual field value as filterable // This sets the individual field value as filterable
filterable: isFieldFilterable(field, logsFrame ?? undefined), filterable: isFieldFilterable(field, logsFrame?.bodyField.name ?? '', logsFrame?.timeField.name ?? ''),
}; };
} }
return frameWithOverrides; return frameWithOverrides;
}, },
[logsSortOrder, timeZone, splitOpen, range] [logsSortOrder, timeZone, splitOpen, range, logsFrame?.bodyField.name, logsFrame?.timeField.name, timeIndex]
); );
useEffect(() => { useEffect(() => {
const prepare = async () => { const prepare = async () => {
// Parse the dataframe to a logFrame if (!logsFrame?.timeField.name || !logsFrame?.bodyField.name) {
const logsFrame = dataFrame ? parseLogsFrame(dataFrame) : undefined;
if (!logsFrame) {
setTableFrame(undefined); setTableFrame(undefined);
return; return;
} }
@ -117,6 +113,10 @@ export function LogsTable(props: Props) {
transformations.push({ transformations.push({
id: 'organize', id: 'organize',
options: { options: {
indexByName: {
[logsFrame.bodyField.name]: 0,
[logsFrame.timeField.name]: 1,
},
includeByName: { includeByName: {
[logsFrame.bodyField.name]: true, [logsFrame.bodyField.name]: true,
[logsFrame.timeField.name]: true, [logsFrame.timeField.name]: true,
@ -134,7 +134,14 @@ export function LogsTable(props: Props) {
} }
}; };
prepare(); prepare();
}, [columnsWithMeta, dataFrame, logsSortOrder, prepareTableFrame]); }, [
columnsWithMeta,
dataFrame,
logsSortOrder,
prepareTableFrame,
logsFrame?.bodyField.name,
logsFrame?.timeField.name,
]);
if (!tableFrame) { if (!tableFrame) {
return null; return null;
@ -166,14 +173,14 @@ export function LogsTable(props: Props) {
); );
} }
const isFieldFilterable = (field: Field, logsFrame?: LogsFrame | undefined) => { const isFieldFilterable = (field: Field, bodyName: string, timeName: string) => {
if (!logsFrame) { if (!bodyName || !timeName) {
return false; return false;
} }
if (logsFrame.bodyField.name === field.name) { if (bodyName === field.name) {
return false; return false;
} }
if (logsFrame.timeField.name === field.name) { if (timeName === field.name) {
return false; return false;
} }
if (field.config.links?.length) { 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 // 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) Object.keys(columnsWithMeta)
.filter((key) => columnsWithMeta[key].active) .filter((key) => columnsWithMeta[key].active)
.forEach((key) => { .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; 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) { if (Object.keys(labelFilters).length > 0) {
return { return {
id: 'organize', id: 'organize',
options: { 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 { GrafanaTheme2 } from '@grafana/data/src';
import { useTheme2 } from '@grafana/ui/src'; import { useTheme2 } from '@grafana/ui/src';
import { LogsTableNavColumn } from './LogsTableNavColumn'; import { LogsTableActiveFields } from './LogsTableActiveFields';
import { fieldNameMeta } from './LogsTableWrap'; import { LogsTableAvailableFields } from './LogsTableAvailableFields';
import { FieldNameMeta } from './LogsTableWrap';
function getStyles(theme: GrafanaTheme2) { function getStyles(theme: GrafanaTheme2) {
return { return {
sidebarWrap: css({ sidebarWrap: css({
overflowY: 'scroll', overflowY: 'scroll',
height: 'calc(100% - 50px)', height: 'calc(100% - 50px)',
/* Hide scrollbar for Chrome, Safari, and Opera */
'&::-webkit-scrollbar': {
display: 'none',
},
/* Hide scrollbar for Firefox */
scrollbarWidth: 'none',
}), }),
columnHeaderButton: css({ columnHeaderButton: css({
appearance: 'none', appearance: 'none',
@ -39,9 +46,10 @@ function getStyles(theme: GrafanaTheme2) {
export const LogsTableMultiSelect = (props: { export const LogsTableMultiSelect = (props: {
toggleColumn: (columnName: string) => void; toggleColumn: (columnName: string) => void;
filteredColumnsWithMeta: Record<string, fieldNameMeta> | undefined; filteredColumnsWithMeta: Record<string, FieldNameMeta> | undefined;
columnsWithMeta: Record<string, fieldNameMeta>; columnsWithMeta: Record<string, FieldNameMeta>;
clear: () => void; clear: () => void;
reorderColumn: (oldIndex: number, newIndex: number) => void;
}) => { }) => {
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme); const styles = getStyles(theme);
@ -56,14 +64,16 @@ export const LogsTableMultiSelect = (props: {
Reset Reset
</button> </button>
</div> </div>
<LogsTableNavColumn <LogsTableActiveFields
reorderColumn={props.reorderColumn}
toggleColumn={props.toggleColumn} toggleColumn={props.toggleColumn}
labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta} labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta}
valueFilter={(value) => props.columnsWithMeta[value]?.active ?? false} valueFilter={(value) => props.columnsWithMeta[value]?.active ?? false}
id={'selected-fields'}
/> />
<div className={styles.columnHeader}>Fields</div> <div className={styles.columnHeader}>Fields</div>
<LogsTableNavColumn <LogsTableAvailableFields
toggleColumn={props.toggleColumn} toggleColumn={props.toggleColumn}
labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta} labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta}
valueFilter={(value) => !props.columnsWithMeta[value]?.active} 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; 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; percentOfLinesWithLabel: number;
active: boolean | undefined;
type?: 'BODY_FIELD' | 'TIME_FIELD'; 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) { export function LogsTableWrap(props: Props) {
const { logsFrames, updatePanelState, panelState } = props; const { logsFrames, updatePanelState, panelState } = props;
const propsColumns = panelState?.columns; const propsColumns = panelState?.columns;
// Save the normalized cardinality of each label // 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 // 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 [searchValue, setSearchValue] = useState<string>('');
const height = getLogsTableHeight(); const height = getLogsTableHeight();
@ -62,12 +74,13 @@ export function LogsTableWrap(props: Props) {
); );
const getColumnsFromProps = useCallback( const getColumnsFromProps = useCallback(
(fieldNames: fieldNameMetaStore) => { (fieldNames: FieldNameMetaStore) => {
const previouslySelected = props.panelState?.columns; const previouslySelected = props.panelState?.columns;
if (previouslySelected) { if (previouslySelected) {
Object.values(previouslySelected).forEach((key) => { Object.values(previouslySelected).forEach((key, index) => {
if (fieldNames[key]) { if (fieldNames[key]) {
fieldNames[key].active = true; 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 // 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 // What the label state will look like
let pendingLabelState: fieldNameMetaStore = {}; let pendingLabelState: FieldNameMetaStore = {};
// If we have labels and log lines // If we have labels and log lines
if (labels?.length && numberOfLogLines) { if (labels?.length && numberOfLogLines) {
@ -169,14 +182,23 @@ export function LogsTableWrap(props: Props) {
if (labelCardinality.has(label)) { if (labelCardinality.has(label)) {
const value = labelCardinality.get(label); const value = labelCardinality.get(label);
if (value) { if (value) {
labelCardinality.set(label, { if (value?.active) {
percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1, labelCardinality.set(label, {
active: value?.active, percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1,
}); active: true,
index: value.index,
});
} else {
labelCardinality.set(label, {
percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1,
active: false,
index: undefined,
});
}
} }
// Otherwise add it // Otherwise add it
} else { } 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 // Normalize the other fields
otherFields.forEach((field) => { otherFields.forEach((field) => {
pendingLabelState[field.name] = { const isActive = pendingLabelState[field.name]?.active;
percentOfLinesWithLabel: normalize( const index = pendingLabelState[field.name]?.index;
field.values.filter((value) => value !== null && value !== undefined).length, if (isActive && index !== undefined) {
numberOfLogLines pendingLabelState[field.name] = {
), percentOfLinesWithLabel: normalize(
active: pendingLabelState[field.name]?.active, 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); pendingLabelState = getColumnsFromProps(pendingLabelState);
@ -255,45 +291,64 @@ export function LogsTableWrap(props: Props) {
const clearSelection = () => { const clearSelection = () => {
const pendingLabelState = { ...columnsWithMeta }; const pendingLabelState = { ...columnsWithMeta };
let index = 0;
Object.keys(pendingLabelState).forEach((key) => { 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); setColumnsWithMeta(pendingLabelState);
}; };
// Toggle a column on or off when the user interacts with an element in the multi-select sidebar const reorderColumn = (sourceIndex: number, destinationIndex: number) => {
const toggleColumn = (columnName: fieldName) => { if (sourceIndex === destinationIndex) {
if (!columnsWithMeta || !(columnName in columnsWithMeta)) {
console.warn('failed to get column', columnsWithMeta);
return; return;
} }
const pendingLabelState = { const pendingLabelState = { ...columnsWithMeta };
...columnsWithMeta,
[columnName]: { ...columnsWithMeta[columnName], active: !columnsWithMeta[columnName]?.active },
};
// Analytics const keys = Object.keys(pendingLabelState)
columnFilterEvent(columnName); .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 // Set local state
setColumnsWithMeta(pendingLabelState); setColumnsWithMeta(pendingLabelState);
// If user is currently filtering, update filtered state // Sync the explore state
if (filteredColumnsWithMeta) { updateExploreState(pendingLabelState);
const pendingFilteredLabelState = { };
...filteredColumnsWithMeta,
[columnName]: { ...filteredColumnsWithMeta[columnName], active: !filteredColumnsWithMeta[columnName]?.active }, function updateExploreState(pendingLabelState: FieldNameMetaStore) {
}; // Get all active columns and sort by index
setFilteredColumnsWithMeta(pendingFilteredLabelState); 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( const newColumns: Record<number, string> = Object.assign(
{}, {},
// Get the keys of the object as an array // Get the keys of the object as an array
Object.keys(pendingLabelState) newColumnsArray
// Only include active filters
.filter((key) => pendingLabelState[key]?.active)
); );
const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' }; const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' };
@ -308,12 +363,79 @@ export function LogsTableWrap(props: Props) {
// Update url state // Update url state
updatePanelState(newPanelState); 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 // uFuzzy search dispatcher, adds any matches to the local state
const dispatcher = (data: string[][]) => { const dispatcher = (data: string[][]) => {
const matches = data[0]; const matches = data[0];
let newColumnsWithMeta: fieldNameMetaStore = {}; let newColumnsWithMeta: FieldNameMetaStore = {};
let numberOfResults = 0; let numberOfResults = 0;
matches.forEach((match) => { matches.forEach((match) => {
if (match in columnsWithMeta) { if (match in columnsWithMeta) {
@ -386,6 +508,7 @@ export function LogsTableWrap(props: Props) {
<section className={styles.sidebar}> <section className={styles.sidebar}>
<LogsColumnSearch value={searchValue} onChange={onSearchInputChange} /> <LogsColumnSearch value={searchValue} onChange={onSearchInputChange} />
<LogsTableMultiSelect <LogsTableMultiSelect
reorderColumn={reorderColumn}
toggleColumn={toggleColumn} toggleColumn={toggleColumn}
filteredColumnsWithMeta={filteredColumnsWithMeta} filteredColumnsWithMeta={filteredColumnsWithMeta}
columnsWithMeta={columnsWithMeta} columnsWithMeta={columnsWithMeta}
@ -393,6 +516,7 @@ export function LogsTableWrap(props: Props) {
/> />
</section> </section>
<LogsTable <LogsTable
logsFrame={logsFrame}
onClickFilterLabel={props.onClickFilterLabel} onClickFilterLabel={props.onClickFilterLabel}
onClickFilterOutLabel={props.onClickFilterOutLabel} onClickFilterOutLabel={props.onClickFilterOutLabel}
logsSortOrder={props.logsSortOrder} logsSortOrder={props.logsSortOrder}

View File

@ -55,6 +55,13 @@ function getLogsTableTransformations(panelType: string, options: AddPanelToDashb
transformations.push({ transformations.push({
id: 'organize', id: 'organize',
options: { 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( includeByName: Object.values(options.panelState.logs.columns).reduce(
(acc: Record<string, boolean>, value: string) => ({ (acc: Record<string, boolean>, value: string) => ({
...acc, ...acc,