mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -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 { 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 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
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 { 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}
|
||||||
|
@ -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;
|
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}
|
||||||
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user