mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 08:05:43 -06:00
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:
parent
581936a442
commit
7b8db643a3
@ -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 },
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
109
public/app/features/explore/Logs/LogsTableActiveFields.tsx
Normal file
109
public/app/features/explore/Logs/LogsTableActiveFields.tsx
Normal 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 />;
|
||||
};
|
@ -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 />;
|
||||
};
|
21
public/app/features/explore/Logs/LogsTableEmptyFields.tsx
Normal file
21
public/app/features/explore/Logs/LogsTableEmptyFields.tsx
Normal 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>;
|
||||
}
|
@ -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}
|
||||
|
@ -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>;
|
||||
};
|
83
public/app/features/explore/Logs/LogsTableNavField.tsx
Normal file
83
public/app/features/explore/Logs/LogsTableNavField.tsx
Normal 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;
|
||||
}
|
@ -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}
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user