Logs Panel: Column selection for experimental table visualization in explore (#76983)

* New experimental table customization for logs in explore
* Logs Panel: Explore url sync for table visualization (#76980)
* Sync explore URL state with logs panel state in explore
This commit is contained in:
Galen Kistler 2023-10-26 12:06:41 -05:00 committed by GitHub
parent c122ffc72b
commit 05376a950c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1331 additions and 251 deletions

View File

@ -46,6 +46,8 @@ export interface ExploreTracePanelState {
export interface ExploreLogsPanelState {
id?: string;
columns?: Record<number, string>;
visualisationType?: 'table' | 'logs';
}
export interface SplitOpenOptions<T extends AnyQuery = AnyQuery> {

View File

@ -3,9 +3,10 @@ import userEvent from '@testing-library/user-event';
import React, { ComponentProps } from 'react';
import {
DataFrame,
EventBusSrv,
ExploreLogsPanelState,
FieldType,
ExplorePanelsState,
LoadingState,
LogLevel,
LogRowModel,
@ -18,6 +19,7 @@ import { config } from '@grafana/runtime';
import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields';
import { Logs } from './Logs';
import { getMockElasticFrame, getMockLokiFrame } from './utils/testMocks.test';
const reportInteraction = jest.fn();
jest.mock('@grafana/runtime', () => ({
@ -95,39 +97,18 @@ describe('Logs', () => {
});
});
const getComponent = (partialProps?: Partial<ComponentProps<typeof Logs>>, logs?: LogRowModel[]) => {
const getComponent = (
partialProps?: Partial<ComponentProps<typeof Logs>>,
dataFrame?: DataFrame,
logs?: LogRowModel[]
) => {
const rows = [
makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1 }),
makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 2 }),
makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 3 }),
];
const testDataFrame = {
fields: [
{
config: {},
name: 'Time',
type: FieldType.time,
values: ['2019-01-01 10:00:00', '2019-01-01 11:00:00', '2019-01-01 12:00:00'],
},
{
config: {},
name: 'line',
type: FieldType.string,
values: ['log message 1', 'log message 2', 'log message 3'],
},
{
config: {},
name: 'labels',
type: FieldType.other,
typeInfo: {
frame: 'json.RawMessage',
},
values: ['{"foo":"bar"}', '{"foo":"bar"}', '{"foo":"bar"}'],
},
],
length: 3,
};
const testDataFrame = dataFrame ?? getMockLokiFrame();
return (
<Logs
exploreId={'left'}
@ -165,8 +146,8 @@ describe('Logs', () => {
/>
);
};
const setup = (partialProps?: Partial<ComponentProps<typeof Logs>>, logs?: LogRowModel[]) => {
return render(getComponent(partialProps, logs));
const setup = (partialProps?: Partial<ComponentProps<typeof Logs>>, dataFrame?: DataFrame, logs?: LogRowModel[]) => {
return render(getComponent(partialProps, dataFrame ? dataFrame : getMockLokiFrame(), logs));
};
describe('scrolling behavior', () => {
@ -202,7 +183,7 @@ describe('Logs', () => {
logs.push(makeLog({ uid: `uid${i}`, rowId: `id${i}`, timeEpochMs: i }));
}
setup({ panelState: { logs: { id: 'uid47' } } }, logs);
setup({ panelState: { logs: { id: 'uid47' } } }, undefined, logs);
expect(scrollIntoViewSpy).toBeCalledTimes(1);
// element.getBoundingClientRect().top will always be 0 for jsdom
@ -232,6 +213,7 @@ describe('Logs', () => {
};
setup(
{ scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState: { logs: { id: 'uid47' } } },
undefined,
logs
);
@ -252,7 +234,7 @@ describe('Logs', () => {
});
it('should render no logs found', () => {
setup({}, []);
setup({}, undefined, []);
expect(screen.getByText(/no logs found\./i)).toBeInTheDocument();
expect(
@ -452,8 +434,8 @@ describe('Logs', () => {
});
});
it('should call createAndCopyShortLink on permalinkClick', async () => {
const panelState = { logs: { id: 'not-included' } };
it('should call createAndCopyShortLink on permalinkClick - logs', async () => {
const panelState: Partial<ExplorePanelsState> = { logs: { id: 'not-included', visualisationType: 'logs' } };
setup({ loading: false, panelState });
const row = screen.getAllByRole('row');
@ -463,8 +445,11 @@ describe('Logs', () => {
await userEvent.click(linkButton);
expect(createAndCopyShortLink).toHaveBeenCalledWith(
'http://localhost:3000/explore?left=%7B%22datasource%22:%22%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22id%22%7D%7D%5D,%22range%22:%7B%22from%22:%222019-01-01T10:00:00.000Z%22,%22to%22:%222019-01-01T16:00:00.000Z%22%7D,%22panelsState%22:%7B%22logs%22:%7B%22id%22:%221%22%7D%7D%7D'
expect.stringMatching(
'range%22:%7B%22from%22:%222019-01-01T10:00:00.000Z%22,%22to%22:%222019-01-01T16:00:00.000Z%22%7D'
)
);
expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('visualisationType%22:%22logs'));
});
});
@ -486,8 +471,17 @@ describe('Logs', () => {
expect(logsSection).toBeInTheDocument();
});
it('should change visualisation to table on toggle', async () => {
setup();
it('should change visualisation to table on toggle (loki)', async () => {
setup({});
const logsSection = screen.getByRole('radio', { name: 'Show results in table visualisation' });
await userEvent.click(logsSection);
const table = screen.getByTestId('logRowsTable');
expect(table).toBeInTheDocument();
});
it('should change visualisation to table on toggle (elastic)', async () => {
setup({}, getMockElasticFrame());
const logsSection = screen.getByRole('radio', { name: 'Show results in table visualisation' });
await userEvent.click(logsSection);

View File

@ -1,52 +1,56 @@
import { css } from '@emotion/css';
import { capitalize } from 'lodash';
import memoizeOne from 'memoize-one';
import React, { PureComponent, createRef } from 'react';
import React, { createRef, PureComponent } from 'react';
import {
rangeUtil,
RawTimeRange,
LogLevel,
TimeZone,
AbsoluteTimeRange,
LogsDedupStrategy,
CoreApp,
DataFrame,
DataHoverClearEvent,
DataHoverEvent,
DataQueryResponse,
EventBus,
ExploreLogsPanelState,
ExplorePanelsState,
FeatureState,
Field,
GrafanaTheme2,
LinkModel,
LoadingState,
LogLevel,
LogRowContextOptions,
LogRowModel,
LogsDedupDescription,
LogsDedupStrategy,
LogsMetaItem,
LogsSortOrder,
LinkModel,
Field,
DataFrame,
GrafanaTheme2,
LoadingState,
SplitOpen,
DataQueryResponse,
CoreApp,
DataHoverEvent,
DataHoverClearEvent,
EventBus,
LogRowContextOptions,
ExplorePanelsState,
rangeUtil,
RawTimeRange,
serializeStateToUrlParam,
urlUtil,
SplitOpen,
TimeRange,
TimeZone,
urlUtil,
} from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import {
RadioButtonGroup,
Button,
FeatureBadge,
InlineField,
InlineFieldRow,
InlineSwitch,
withTheme2,
Themeable2,
PanelChrome,
RadioButtonGroup,
Themeable2,
withTheme2,
} from '@grafana/ui';
import store from 'app/core/store';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { getState, dispatch } from 'app/store/store';
import { dispatch, getState } from 'app/store/store';
import { ExploreItemState } from '../../../types';
import { LogRows } from '../../logs/components/LogRows';
import { LogRowContextModal } from '../../logs/components/log-context/LogRowContextModal';
import { dedupLogRows, filterLogLevels } from '../../logs/logsModel';
@ -55,7 +59,7 @@ import { changePanelState } from '../state/explorePane';
import { LogsMetaRow } from './LogsMetaRow';
import LogsNavigation from './LogsNavigation';
import { LogsTable } from './LogsTable';
import { LogsTableWrap } from './LogsTableWrap';
import { LogsVolumePanelList } from './LogsVolumePanelList';
import { SETTINGS_KEYS } from './utils/logs';
@ -100,7 +104,7 @@ interface Props extends Themeable2 {
range: TimeRange;
}
type VisualisationType = 'table' | 'logs';
export type LogsVisualisationType = 'table' | 'logs';
interface State {
showLabels: boolean;
@ -116,7 +120,7 @@ interface State {
contextOpen: boolean;
contextRow?: LogRowModel;
tableFrame?: DataFrame;
visualisationType?: VisualisationType;
visualisationType?: LogsVisualisationType;
logsContainer?: HTMLDivElement;
}
@ -150,7 +154,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
contextOpen: false,
contextRow: undefined,
tableFrame: undefined,
visualisationType: 'logs',
visualisationType: this.props.panelState?.logs?.visualisationType ?? 'logs',
logsContainer: undefined,
};
@ -169,6 +173,19 @@ class UnthemedLogs extends PureComponent<Props, State> {
}
}
updatePanelState = (logsPanelState: Partial<ExploreLogsPanelState>) => {
const state: ExploreItemState | undefined = getState().explore.panes[this.props.exploreId];
if (state?.panelsState) {
dispatch(
changePanelState(this.props.exploreId, 'logs', {
...state.panelsState.logs,
columns: logsPanelState.columns ?? this.props.panelState?.logs?.columns,
visualisationType: logsPanelState.visualisationType ?? this.state.visualisationType,
})
);
}
};
componentDidUpdate(prevProps: Readonly<Props>): void {
if (this.props.loading && !prevProps.loading && this.props.panelState?.logs?.id) {
// loading stopped, so we need to remove any permalinked log lines
@ -179,6 +196,11 @@ class UnthemedLogs extends PureComponent<Props, State> {
})
);
}
if (this.props.panelState?.logs?.visualisationType !== prevProps.panelState?.logs?.visualisationType) {
this.setState({
visualisationType: this.props.panelState?.logs?.visualisationType ?? 'logs',
});
}
}
onLogRowHover = (row?: LogRowModel) => {
@ -219,10 +241,19 @@ class UnthemedLogs extends PureComponent<Props, State> {
}));
};
onChangeVisualisation = (visualisation: VisualisationType) => {
onChangeVisualisation = (visualisation: LogsVisualisationType) => {
this.setState(() => ({
visualisationType: visualisation,
}));
const payload = {
...this.props.panelState?.logs,
visualisationType: visualisation,
};
this.updatePanelState(payload);
reportInteraction('grafana_explore_logs_visualisation_changed', {
newVisualizationType: visualisation,
});
};
onChangeDedup = (dedupStrategy: LogsDedupStrategy) => {
@ -380,7 +411,10 @@ class UnthemedLogs extends PureComponent<Props, State> {
// get explore state, add log-row-id and make timerange absolute
const urlState = getUrlStateFromPaneState(getState().explore.panes[this.props.exploreId]!);
urlState.panelsState = { ...this.props.panelState, logs: { id: row.uid } };
urlState.panelsState = {
...this.props.panelState,
logs: { id: row.uid, visualisationType: this.state.visualisationType ?? 'logs' },
};
urlState.range = {
from: new Date(this.props.absoluteRange.from).toISOString(),
to: new Date(this.props.absoluteRange.to).toISOString(),
@ -552,6 +586,18 @@ class UnthemedLogs extends PureComponent<Props, State> {
)}
</PanelChrome>
<PanelChrome
titleItems={[
config.featureToggles.logsExploreTableVisualisation ? (
this.state.visualisationType === 'logs' ? null : (
<PanelChrome.TitleItem title="Experimental" key="A">
<FeatureBadge
featureState={FeatureState.beta}
tooltip="This feature is experimental and may change in future versions"
/>
</PanelChrome.TitleItem>
)
) : null,
]}
title={
config.featureToggles.logsExploreTableVisualisation
? this.state.visualisationType === 'logs'
@ -681,14 +727,19 @@ class UnthemedLogs extends PureComponent<Props, State> {
<div className={styles.logsSection}>
{this.state.visualisationType === 'table' && hasData && (
<div className={styles.logRows} data-testid="logRowsTable">
{/* Width should be full width minus logsnavigation and padding */}
<LogsTable
{/* Width should be full width minus logs navigation and padding */}
<LogsTableWrap
logsSortOrder={this.state.logsSortOrder}
range={this.props.range}
splitOpen={this.props.splitOpen}
timeZone={timeZone}
width={width - 80}
logsFrames={this.props.logsFrames}
logsFrames={this.props.logsFrames ?? []}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
panelState={this.props.panelState?.logs}
theme={theme}
updatePanelState={this.updatePanelState}
/>
</div>
)}

View File

@ -0,0 +1,23 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data/src';
import { Field, Input, useTheme2 } from '@grafana/ui/src';
function getStyles(theme: GrafanaTheme2) {
return {
searchWrap: css({
padding: theme.spacing(0.4),
}),
};
}
export function LogsColumnSearch(props: { onChange: (e: React.FormEvent<HTMLInputElement>) => void }) {
const theme = useTheme2();
const styles = getStyles(theme);
return (
<Field className={styles.searchWrap}>
<Input type={'text'} placeholder={'Search fields by name'} onChange={props.onChange} />
</Field>
);
}

View File

@ -7,6 +7,7 @@ import { config } from '@grafana/runtime';
import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields';
import { LogsTable } from './LogsTable';
import { getMockElasticFrame, getMockLokiFrame, getMockLokiFrameDataPlane } from './utils/testMocks.test';
jest.mock('@grafana/runtime', () => {
const actual = jest.requireActual('@grafana/runtime');
@ -18,6 +19,68 @@ jest.mock('@grafana/runtime', () => {
};
});
const getComponent = (partialProps?: Partial<ComponentProps<typeof LogsTable>>, logs?: DataFrame) => {
const testDataFrame = {
fields: [
{
config: {},
name: 'Time',
type: FieldType.time,
values: ['2019-01-01 10:00:00', '2019-01-01 11:00:00', '2019-01-01 12:00:00'],
},
{
config: {},
name: 'line',
type: FieldType.string,
values: ['log message 1', 'log message 2', 'log message 3'],
},
{
config: {},
name: 'tsNs',
type: FieldType.string,
values: ['ts1', 'ts2', 'ts3'],
},
{
config: {},
name: 'labels',
type: FieldType.other,
typeInfo: {
frame: 'json.RawMessage',
},
values: [{ foo: 'bar' }, { foo: 'bar' }, { foo: 'bar' }],
},
],
length: 3,
};
return (
<LogsTable
height={400}
columnsWithMeta={{}}
logsSortOrder={LogsSortOrder.Descending}
splitOpen={() => undefined}
timeZone={'utc'}
width={50}
range={{
from: toUtc('2019-01-01 10:00:00'),
to: toUtc('2019-01-01 16:00:00'),
raw: { from: 'now-1h', to: 'now' },
}}
logsFrames={[logs ?? testDataFrame]}
{...partialProps}
/>
);
};
const setup = (partialProps?: Partial<ComponentProps<typeof LogsTable>>, logs?: DataFrame) => {
return render(
getComponent(
{
...partialProps,
},
logs
)
);
};
describe('LogsTable', () => {
beforeAll(() => {
const transformers = [extractFieldsTransformer, organizeFieldsTransformer];
@ -35,59 +98,6 @@ describe('LogsTable', () => {
});
});
const getComponent = (partialProps?: Partial<ComponentProps<typeof LogsTable>>, logs?: DataFrame) => {
const testDataFrame = {
fields: [
{
config: {},
name: 'Time',
type: FieldType.time,
values: ['2019-01-01 10:00:00', '2019-01-01 11:00:00', '2019-01-01 12:00:00'],
},
{
config: {},
name: 'line',
type: FieldType.string,
values: ['log message 1', 'log message 2', 'log message 3'],
},
{
config: {},
name: 'tsNs',
type: FieldType.string,
values: ['ts1', 'ts2', 'ts3'],
},
{
config: {},
name: 'labels',
type: FieldType.other,
typeInfo: {
frame: 'json.RawMessage',
},
values: ['{"foo":"bar"}', '{"foo":"bar"}', '{"foo":"bar"}'],
},
],
length: 3,
};
return (
<LogsTable
logsSortOrder={LogsSortOrder.Descending}
splitOpen={() => undefined}
timeZone={'utc'}
width={50}
range={{
from: toUtc('2019-01-01 10:00:00'),
to: toUtc('2019-01-01 16:00:00'),
raw: { from: 'now-1h', to: 'now' },
}}
logsFrames={[logs ?? testDataFrame]}
{...partialProps}
/>
);
};
const setup = (partialProps?: Partial<ComponentProps<typeof LogsTable>>, logs?: DataFrame) => {
return render(getComponent(partialProps, logs));
};
let originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation;
beforeAll(() => {
@ -109,18 +119,26 @@ describe('LogsTable', () => {
});
});
it('should render 4 table rows', async () => {
setup();
it('should render extracted labels as columns (elastic)', async () => {
setup({
logsFrames: [getMockElasticFrame()],
});
await waitFor(() => {
const rows = screen.getAllByRole('row');
// tableFrame has 3 rows + 1 header row
expect(rows.length).toBe(4);
const columns = screen.getAllByRole('columnheader');
expect(columns[0].textContent).toContain('@timestamp');
expect(columns[1].textContent).toContain('line');
expect(columns[2].textContent).toContain('counter');
expect(columns[3].textContent).toContain('level');
});
});
it('should render extracted labels as columns', async () => {
setup();
it('should render extracted labels as columns (loki)', async () => {
setup({
columnsWithMeta: {
foo: { active: true, percentOfLinesWithLabel: 3 },
},
});
await waitFor(() => {
const columns = screen.getAllByRole('columnheader');
@ -132,7 +150,7 @@ describe('LogsTable', () => {
});
it('should not render `tsNs`', async () => {
setup();
setup(undefined, getMockLokiFrame());
await waitFor(() => {
const columns = screen.queryAllByRole('columnheader', { name: 'tsNs' });
@ -141,47 +159,86 @@ describe('LogsTable', () => {
});
});
it('should render a datalink for each row', async () => {
render(
getComponent(
{},
{
fields: [
{
config: {},
name: 'Time',
type: FieldType.time,
values: ['2019-01-01 10:00:00', '2019-01-01 11:00:00', '2019-01-01 12:00:00'],
},
{
config: {},
name: 'line',
type: FieldType.string,
values: ['log message 1', 'log message 2', 'log message 3'],
},
{
config: {
links: [
{
url: 'http://example.com',
title: 'foo',
},
],
},
name: 'link',
type: FieldType.string,
values: ['ts1', 'ts2', 'ts3'],
},
],
length: 3,
}
)
);
it('should not render `labels`', async () => {
setup();
await waitFor(() => {
const links = screen.getAllByRole('link');
const columns = screen.queryAllByRole('columnheader', { name: 'labels' });
expect(links.length).toBe(3);
expect(columns.length).toBe(0);
});
});
describe('LogsTable (loki dataplane)', () => {
let originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation;
let originalLokiDataplaneValue = config.featureToggles.lokiLogsDataplane;
beforeAll(() => {
originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation;
originalLokiDataplaneValue = config.featureToggles.lokiLogsDataplane;
config.featureToggles.logsExploreTableVisualisation = true;
config.featureToggles.lokiLogsDataplane = true;
});
afterAll(() => {
config.featureToggles.logsExploreTableVisualisation = originalVisualisationTypeValue;
config.featureToggles.lokiLogsDataplane = originalLokiDataplaneValue;
});
it('should render 4 table rows', async () => {
setup(undefined, getMockLokiFrameDataPlane());
await waitFor(() => {
const rows = screen.getAllByRole('row');
// tableFrame has 3 rows + 1 header row
expect(rows.length).toBe(4);
});
});
it('should render a datalink for each row', async () => {
render(getComponent({}, getMockLokiFrameDataPlane()));
await waitFor(() => {
const links = screen.getAllByRole('link');
expect(links.length).toBe(3);
});
});
it('should not render `attributes`', async () => {
setup(undefined, getMockLokiFrameDataPlane());
await waitFor(() => {
const columns = screen.queryAllByRole('columnheader', { name: 'attributes' });
expect(columns.length).toBe(0);
});
});
it('should not render `tsNs`', async () => {
setup(undefined, getMockLokiFrameDataPlane());
await waitFor(() => {
const columns = screen.queryAllByRole('columnheader', { name: 'tsNs' });
expect(columns.length).toBe(0);
});
});
it('should render extracted labels as columns (loki dataplane)', async () => {
setup({
columnsWithMeta: {
foo: { active: true, percentOfLinesWithLabel: 3 },
},
});
await waitFor(() => {
const columns = screen.getAllByRole('columnheader');
expect(columns[0].textContent).toContain('Time');
expect(columns[1].textContent).toContain('line');
expect(columns[2].textContent).toContain('foo');
});
});
});
});

View File

@ -1,11 +1,14 @@
import memoizeOne from 'memoize-one';
import React, { useCallback, useEffect, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import {
applyFieldOverrides,
CustomTransformOperator,
DataFrame,
DataFrameType,
DataTransformerConfig,
Field,
FieldType,
LogsSortOrder,
sortDataFrame,
SplitOpen,
@ -14,38 +17,41 @@ import {
ValueLinkConfig,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { 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 { separateVisibleFields } from 'app/features/logs/components/logParser';
import { parseLogsFrame } from 'app/features/logs/logsFrame';
import { LogsFrame, parseLogsFrame } from 'app/features/logs/logsFrame';
import { getFieldLinksForExplore } from '../utils/links';
import { fieldNameMeta } from './LogsTableWrap';
interface Props {
logsFrames?: DataFrame[];
logsFrames: DataFrame[];
width: number;
timeZone: string;
splitOpen: SplitOpen;
range: TimeRange;
logsSortOrder: LogsSortOrder;
columnsWithMeta: Record<string, fieldNameMeta>;
height: number;
onClickFilterLabel?: (key: string, value: string, refId?: string) => void;
onClickFilterOutLabel?: (key: string, value: string, refId?: string) => void;
}
const getTableHeight = memoizeOne((dataFrames: DataFrame[] | undefined) => {
const largestFrameLength = dataFrames?.reduce((length, frame) => {
return frame.length > length ? frame.length : length;
}, 0);
// from TableContainer.tsx
return Math.min(600, Math.max(largestFrameLength ?? 0 * 36, 300) + 40 + 46);
});
export const LogsTable: React.FunctionComponent<Props> = (props) => {
const { timeZone, splitOpen, range, logsSortOrder, width, logsFrames } = props;
export function LogsTable(props: Props) {
const { timeZone, splitOpen, range, logsSortOrder, width, logsFrames, columnsWithMeta } = props;
const [tableFrame, setTableFrame] = useState<DataFrame | undefined>(undefined);
// Only a single frame (query) is supported currently
const logFrameRaw = logsFrames ? logsFrames[0] : undefined;
const prepareTableFrame = useCallback(
(frame: DataFrame): DataFrame => {
// Parse the dataframe to a logFrame
const logsFrame = parseLogsFrame(frame);
const timeIndex = logsFrame?.timeField.index;
const sortedFrame = sortDataFrame(frame, timeIndex, logsSortOrder === LogsSortOrder.Descending);
const [frameWithOverrides] = applyFieldOverrides({
@ -74,93 +80,203 @@ export const LogsTable: React.FunctionComponent<Props> = (props) => {
field.config = {
...field.config,
custom: {
filterable: true,
inspect: true,
filterable: true, // This sets the columns to be filterable
...field.config.custom,
},
// This sets the individual field value as filterable
filterable: isFieldFilterable(field, logsFrame ?? undefined),
};
}
return frameWithOverrides;
},
[logsSortOrder, range, splitOpen, timeZone]
[logsSortOrder, timeZone, splitOpen, range]
);
useEffect(() => {
const prepare = async () => {
if (!logsFrames || !logsFrames.length) {
// Parse the dataframe to a logFrame
const logsFrame = logFrameRaw ? parseLogsFrame(logFrameRaw) : undefined;
if (!logFrameRaw || !logsFrame) {
setTableFrame(undefined);
return;
}
// TODO: This does not work with multiple logs queries for now, as we currently only support one logs frame.
let dataFrame = logsFrames[0];
const logsFrame = parseLogsFrame(dataFrame);
const timeIndex = logsFrame?.timeField.index;
dataFrame = sortDataFrame(dataFrame, timeIndex, logsSortOrder === LogsSortOrder.Descending);
let dataFrame = logFrameRaw;
// create extract JSON transformation for every field that is `json.RawMessage`
// TODO: explore if `logsFrame.ts` can help us with getting the right fields
const transformations = dataFrame.fields
.filter((field: Field & { typeInfo?: { frame: string } }) => {
return field.typeInfo?.frame === 'json.RawMessage';
})
.flatMap((field: Field) => {
return [
{
id: 'extractFields',
options: {
format: 'json',
keepTime: false,
replace: false,
source: field.name,
},
},
// hide the field that was extracted
{
id: 'organize',
options: {
excludeByName: {
[field.name]: true,
},
},
},
];
});
const transformations: Array<DataTransformerConfig | CustomTransformOperator> =
extractFieldsAndExclude(dataFrame);
// remove fields that should not be displayed
// remove hidden fields
transformations.push(...removeHiddenFields(dataFrame));
let labelFilters = buildLabelFilters(columnsWithMeta, logsFrame);
const hiddenFields = separateVisibleFields(dataFrame, { keepBody: true, keepTimestamp: true }).hidden;
hiddenFields.forEach((field: Field, index: number) => {
transformations.push({
// Add the label filters to the transformations
const transform = getLabelFiltersTransform(labelFilters);
if (transform) {
transformations.push(transform);
}
if (transformations.length > 0) {
const transformedDataFrame = await lastValueFrom(transformDataFrame(transformations, [dataFrame]));
const tableFrame = prepareTableFrame(transformedDataFrame[0]);
setTableFrame(tableFrame);
} else {
setTableFrame(prepareTableFrame(dataFrame));
}
};
prepare();
}, [columnsWithMeta, logFrameRaw, logsSortOrder, prepareTableFrame]);
if (!tableFrame) {
return null;
}
const onCellFilterAdded = (filter: AdHocFilterItem) => {
const { value, key, operator } = filter;
const { onClickFilterLabel, onClickFilterOutLabel } = props;
if (!onClickFilterLabel || !onClickFilterOutLabel) {
return;
}
if (operator === FILTER_FOR_OPERATOR) {
onClickFilterLabel(key, value);
}
if (operator === FILTER_OUT_OPERATOR) {
onClickFilterOutLabel(key, value);
}
};
return (
<Table
data={tableFrame}
width={width}
onCellFilterAdded={props.onClickFilterLabel && props.onClickFilterOutLabel ? onCellFilterAdded : undefined}
height={props.height}
footerOptions={{ show: true, reducer: ['count'], countRows: true }}
/>
);
}
const isFieldFilterable = (field: Field, logsFrame?: LogsFrame | undefined) => {
if (!logsFrame) {
return false;
}
if (logsFrame.bodyField.name === field.name) {
return false;
}
if (logsFrame.timeField.name === field.name) {
return false;
}
// @todo not currently excluding derived fields from filtering
return true;
};
// TODO: explore if `logsFrame.ts` can help us with getting the right fields
// TODO Why is typeInfo not defined on the Field interface?
function extractFieldsAndExclude(dataFrame: DataFrame) {
return dataFrame.fields
.filter((field: Field & { typeInfo?: { frame: string } }) => {
const isFieldLokiLabels = field.typeInfo?.frame === 'json.RawMessage' && field.name === 'labels';
const isFieldDataplaneLabels =
field.name === 'attributes' &&
field.type === FieldType.other &&
dataFrame?.meta?.type === DataFrameType.LogLines;
return isFieldLokiLabels || isFieldDataplaneLabels;
})
.flatMap((field: Field) => {
return [
{
id: 'extractFields',
options: {
format: 'json',
keepTime: false,
replace: false,
source: field.name,
},
},
// hide the field that was extracted
{
id: 'organize',
options: {
excludeByName: {
[field.name]: true,
},
},
});
});
if (transformations.length > 0) {
const [transformedDataFrame] = await lastValueFrom(transformDataFrame(transformations, [dataFrame]));
setTableFrame(prepareTableFrame(transformedDataFrame));
} else {
setTableFrame(prepareTableFrame(dataFrame));
}
},
];
});
}
function removeHiddenFields(dataFrame: DataFrame): Array<DataTransformerConfig | CustomTransformOperator> {
const transformations: Array<DataTransformerConfig | CustomTransformOperator> = [];
const hiddenFields = separateVisibleFields(dataFrame, { keepBody: true, keepTimestamp: true }).hidden;
hiddenFields.forEach((field: Field) => {
transformations.push({
id: 'organize',
options: {
excludeByName: {
[field.name]: true,
},
},
});
});
return transformations;
}
function buildLabelFilters(columnsWithMeta: Record<string, fieldNameMeta>, logsFrame: LogsFrame) {
// Create object of label filters to filter out any columns not selected by the user
let labelFilters: Record<string, true> = {};
Object.keys(columnsWithMeta)
.filter((key) => !columnsWithMeta[key].active)
.forEach((key) => {
labelFilters[key] = true;
});
// We could be getting fresh data
const uniqueLabels = new Set<string>();
const logFrameLabels = logsFrame?.getAttributesAsLabels();
// Populate the set with all labels from latest dataframe
logFrameLabels?.forEach((labels) => {
Object.keys(labels).forEach((label) => {
uniqueLabels.add(label);
});
});
// Check if there are labels in the data, that aren't yet in the labelFilters, and set them to be hidden by the transform
Object.keys(labelFilters).forEach((label) => {
if (!uniqueLabels.has(label)) {
labelFilters[label] = true;
}
});
// Check if there are labels in the label filters that aren't yet in the data, and set those to also be hidden
// The next time the column filters are synced any extras will be removed
Array.from(uniqueLabels).forEach((label) => {
if (label in columnsWithMeta && !columnsWithMeta[label]?.active) {
labelFilters[label] = true;
} else if (!labelFilters[label] && !(label in columnsWithMeta)) {
labelFilters[label] = true;
}
});
return labelFilters;
}
function getLabelFiltersTransform(labelFilters: Record<string, true>) {
if (Object.keys(labelFilters).length > 0) {
return {
id: 'organize',
options: {
excludeByName: labelFilters,
},
};
prepare();
}, [prepareTableFrame, logsFrames, logsSortOrder]);
if (!tableFrame) {
return null;
}
return (
<Table
data={tableFrame}
width={width}
height={getTableHeight(props.logsFrames)}
footerOptions={{ show: true, reducer: ['count'], countRows: true }}
/>
);
};
return null;
}

View File

@ -0,0 +1,53 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data/src';
import { useTheme2 } from '@grafana/ui/src';
import { LogsTableNavColumn } from './LogsTableNavColumn';
import { fieldNameMeta } from './LogsTableWrap';
function getStyles(theme: GrafanaTheme2) {
return {
sidebarWrap: css({
overflowY: 'scroll',
height: 'calc(100% - 50px)',
}),
columnHeader: css({
fontSize: theme.typography.h6.fontSize,
background: theme.colors.background.secondary,
position: 'sticky',
top: 0,
left: 0,
paddingTop: theme.spacing(0.75),
paddingRight: theme.spacing(0.75),
paddingBottom: theme.spacing(0.75),
paddingLeft: theme.spacing(1.5),
zIndex: 3,
marginBottom: theme.spacing(2),
}),
};
}
export const LogsTableMultiSelect = (props: {
toggleColumn: (columnName: string) => void;
filteredColumnsWithMeta: Record<string, fieldNameMeta> | undefined;
columnsWithMeta: Record<string, fieldNameMeta>;
}) => {
const theme = useTheme2();
const styles = getStyles(theme);
return (
<div className={styles.sidebarWrap}>
{/* Sidebar columns */}
<>
<div className={styles.columnHeader}>Fields</div>
<LogsTableNavColumn
toggleColumn={props.toggleColumn}
labels={props.filteredColumnsWithMeta ?? props.columnsWithMeta}
valueFilter={(value) => !!value}
/>
</>
</div>
);
};

View File

@ -0,0 +1,64 @@
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),
}),
wrap: css({
display: 'flex',
alignItems: 'center',
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
justifyContent: 'space-between',
}),
checkbox: css({}),
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,
}),
};
}
export const LogsTableNavColumn = (props: {
labels: Record<string, fieldNameMeta>;
valueFilter: (value: number) => 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(labels[labelName].percentOfLinesWithLabel));
if (labelKeys.length) {
return (
<div className={styles.columnWrapper}>
{labelKeys.map((labelName) => (
<div className={styles.wrap} key={labelName}>
<Checkbox
className={styles.checkbox}
label={labelName}
onChange={() => toggleColumn(labelName)}
checked={labels[labelName]?.active ?? false}
/>
<span className={styles.labelCount}>({labels[labelName]?.percentOfLinesWithLabel}%)</span>
</div>
))}
</div>
);
}
return <div className={styles.empty}>No fields</div>;
};

View File

@ -0,0 +1,165 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React, { ComponentProps } from 'react';
import {
createTheme,
ExploreLogsPanelState,
LogsSortOrder,
standardTransformersRegistry,
toUtc,
} from '@grafana/data/src';
import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize';
import { config } from '@grafana/runtime';
import { extractFieldsTransformer } from '../../transformers/extractFields/extractFields';
import { LogsTableWrap } from './LogsTableWrap';
import { getMockLokiFrame, getMockLokiFrameDataPlane } from './utils/testMocks.test';
const getComponent = (partialProps?: Partial<ComponentProps<typeof LogsTableWrap>>) => {
return (
<LogsTableWrap
range={{
from: toUtc('2019-01-01 10:00:00'),
to: toUtc('2019-01-01 16:00:00'),
raw: { from: 'now-1h', to: 'now' },
}}
onClickFilterOutLabel={() => undefined}
onClickFilterLabel={() => undefined}
updatePanelState={() => undefined}
panelState={undefined}
logsSortOrder={LogsSortOrder.Descending}
splitOpen={() => undefined}
timeZone={'utc'}
width={50}
logsFrames={[getMockLokiFrame()]}
theme={createTheme()}
{...partialProps}
/>
);
};
const setup = (partialProps?: Partial<ComponentProps<typeof LogsTableWrap>>) => {
return render(getComponent(partialProps));
};
describe('LogsTableWrap', () => {
beforeAll(() => {
const transformers = [extractFieldsTransformer, organizeFieldsTransformer];
standardTransformersRegistry.setInit(() => {
return transformers.map((t) => {
return {
id: t.id,
aliasIds: t.aliasIds,
name: t.name,
transformation: t,
description: t.description,
editor: () => null,
};
});
});
});
it('should render 4 table rows', async () => {
setup();
await waitFor(() => {
const rows = screen.getAllByRole('row');
// tableFrame has 3 rows + 1 header row
expect(rows.length).toBe(4);
});
});
it('should render 4 table rows (dataplane)', async () => {
config.featureToggles.lokiLogsDataplane = true;
setup({ logsFrames: [getMockLokiFrameDataPlane()] });
await waitFor(() => {
const rows = screen.getAllByRole('row');
// tableFrame has 3 rows + 1 header row
expect(rows.length).toBe(4);
});
});
it('updatePanelState should be called when a column is selected', async () => {
const updatePanelState = jest.fn() as (panelState: Partial<ExploreLogsPanelState>) => void;
setup({
panelState: {
visualisationType: 'table',
columns: undefined,
},
updatePanelState: updatePanelState,
});
expect.assertions(3);
const checkboxLabel = screen.getByLabelText('app');
expect(checkboxLabel).toBeInTheDocument();
// Add a new column
await waitFor(() => {
checkboxLabel.click();
expect(updatePanelState).toBeCalledWith({
visualisationType: 'table',
columns: { 0: 'app' },
});
});
// Remove the same column
await waitFor(() => {
checkboxLabel.click();
expect(updatePanelState).toBeCalledWith({
visualisationType: 'table',
columns: {},
});
});
});
it('search input should search matching columns', async () => {
config.featureToggles.lokiLogsDataplane = false;
const updatePanelState = jest.fn() as (panelState: Partial<ExploreLogsPanelState>) => void;
setup({
panelState: {
visualisationType: 'table',
columns: undefined,
},
updatePanelState: updatePanelState,
});
await waitFor(() => {
expect(screen.getByLabelText('app')).toBeInTheDocument();
expect(screen.getByLabelText('cluster')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Search fields by name');
fireEvent.change(searchInput, { target: { value: 'app' } });
expect(screen.getByLabelText('app')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByLabelText('cluster')).not.toBeInTheDocument();
});
});
it('search input should search matching columns (dataplane)', async () => {
config.featureToggles.lokiLogsDataplane = true;
const updatePanelState = jest.fn() as (panelState: Partial<ExploreLogsPanelState>) => void;
setup({
panelState: {},
updatePanelState: updatePanelState,
logsFrames: [getMockLokiFrameDataPlane()],
});
await waitFor(() => {
expect(screen.getByLabelText('app')).toBeInTheDocument();
expect(screen.getByLabelText('cluster')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Search fields by name');
fireEvent.change(searchInput, { target: { value: 'app' } });
expect(screen.getByLabelText('app')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByLabelText('cluster')).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,339 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import React, { useState, useEffect, useCallback } from 'react';
import {
DataFrame,
ExploreLogsPanelState,
GrafanaTheme2,
Labels,
LogsSortOrder,
SplitOpen,
TimeRange,
} from '@grafana/data';
import { reportInteraction } from '@grafana/runtime/src';
import { Themeable2 } from '@grafana/ui/';
import { parseLogsFrame } from '../../logs/logsFrame';
import { LogsColumnSearch } from './LogsColumnSearch';
import { LogsTable } from './LogsTable';
import { LogsTableMultiSelect } from './LogsTableMultiSelect';
import { fuzzySearch } from './utils/uFuzzy';
interface Props extends Themeable2 {
logsFrames: DataFrame[];
width: number;
timeZone: string;
splitOpen: SplitOpen;
range: TimeRange;
logsSortOrder: LogsSortOrder;
panelState: ExploreLogsPanelState | undefined;
updatePanelState: (panelState: Partial<ExploreLogsPanelState>) => void;
onClickFilterLabel?: (key: string, value: string, refId?: string) => void;
onClickFilterOutLabel?: (key: string, value: string, refId?: string) => void;
}
export type fieldNameMeta = { percentOfLinesWithLabel: number; active: boolean | undefined };
type fieldName = string;
type fieldNameMetaStore = Record<fieldName, fieldNameMeta>;
export function LogsTableWrap(props: Props) {
const { logsFrames } = props;
// Save the normalized cardinality of each label
const [columnsWithMeta, setColumnsWithMeta] = useState<fieldNameMetaStore | undefined>(undefined);
// Filtered copy of columnsWithMeta that only includes matching results
const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState<fieldNameMetaStore | undefined>(undefined);
const [height, setHeight] = useState<number>(600);
const dataFrame = logsFrames[0];
const getColumnsFromProps = useCallback(
(fieldNames: fieldNameMetaStore) => {
const previouslySelected = props.panelState?.columns;
if (previouslySelected) {
Object.values(previouslySelected).forEach((key) => {
if (fieldNames[key]) {
fieldNames[key].active = true;
}
});
}
return fieldNames;
},
[props.panelState?.columns]
);
/**
* Keeps the filteredColumnsWithMeta state in sync with the columnsWithMeta state,
* which can be updated by explore browser history state changes
* This prevents an edge case bug where the user is navigating while a search is open.
*/
useEffect(() => {
if (!columnsWithMeta || !filteredColumnsWithMeta) {
return;
}
let newFiltered = { ...filteredColumnsWithMeta };
let flag = false;
Object.keys(columnsWithMeta).forEach((key) => {
if (newFiltered[key] && newFiltered[key].active !== columnsWithMeta[key].active) {
newFiltered[key] = columnsWithMeta[key];
flag = true;
}
});
if (flag) {
setFilteredColumnsWithMeta(newFiltered);
}
}, [columnsWithMeta, filteredColumnsWithMeta]);
/**
* when the query results change, we need to update the columnsWithMeta state
* and reset any local search state
*
* This will also find all the unique labels, and calculate how many log lines have each label into the labelCardinality Map
* Then it normalizes the counts
*
*/
useEffect(() => {
const numberOfLogLines = dataFrame ? dataFrame.length : 0;
const logsFrame = parseLogsFrame(dataFrame);
const labels = logsFrame?.getAttributesAsLabels();
const otherFields = logsFrame ? logsFrame.extraFields.filter((field) => !field?.config?.custom?.hidden) : [];
if (logsFrame?.severityField) {
otherFields.push(logsFrame?.severityField);
}
// Use a map to dedupe labels and count their occurrences in the logs
const labelCardinality = new Map<fieldName, fieldNameMeta>();
// What the label state will look like
let pendingLabelState: fieldNameMetaStore = {};
// If we have labels and log lines
if (labels?.length && numberOfLogLines) {
// Iterate through all of Labels
labels.forEach((labels: Labels) => {
const labelsArray = Object.keys(labels);
// Iterate through the label values
labelsArray.forEach((label) => {
// If it's already in our map, increment the count
if (labelCardinality.has(label)) {
const value = labelCardinality.get(label);
if (value) {
labelCardinality.set(label, {
percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1,
active: value?.active,
});
}
// Otherwise add it
} else {
labelCardinality.set(label, { percentOfLinesWithLabel: 1, active: undefined });
}
});
});
// Converting the map to an object
pendingLabelState = Object.fromEntries(labelCardinality);
// Convert count to percent of log lines
Object.keys(pendingLabelState).forEach((key) => {
pendingLabelState[key].percentOfLinesWithLabel = normalize(
pendingLabelState[key].percentOfLinesWithLabel,
numberOfLogLines
);
});
}
// 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,
};
});
pendingLabelState = getColumnsFromProps(pendingLabelState);
setColumnsWithMeta(pendingLabelState);
// The panel state is updated when the user interacts with the multi-select sidebar
}, [dataFrame, getColumnsFromProps]);
// As the number of rows change, so too must the height of the table
useEffect(() => {
setHeight(getTableHeight(dataFrame.length, false));
}, [dataFrame.length]);
if (!columnsWithMeta) {
return null;
}
function columnFilterEvent(columnName: string) {
if (columnsWithMeta) {
const newState = !columnsWithMeta[columnName]?.active;
const priorActiveCount = Object.keys(columnsWithMeta).filter((column) => columnsWithMeta[column]?.active)?.length;
const event = {
columnAction: newState ? 'add' : 'remove',
columnCount: newState ? priorActiveCount + 1 : priorActiveCount - 1,
};
reportInteraction('grafana_explore_logs_table_column_filter_clicked', event);
}
}
function searchFilterEvent(searchResultCount: number) {
reportInteraction('grafana_explore_logs_table_text_search_result_count', {
resultCount: searchResultCount,
});
}
// 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 pendingLabelState = {
...columnsWithMeta,
[columnName]: { ...columnsWithMeta[columnName], active: !columnsWithMeta[columnName]?.active },
};
// Analytics
columnFilterEvent(columnName);
// 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);
}
const newPanelState: ExploreLogsPanelState = {
...props.panelState,
// URL format requires our array of values be an object, so we convert it using object.assign
columns: Object.assign(
{},
// Get the keys of the object as an array
Object.keys(pendingLabelState)
// Only include active filters
.filter((key) => pendingLabelState[key]?.active)
),
visualisationType: 'table',
};
// Update url state
props.updatePanelState(newPanelState);
};
// uFuzzy search dispatcher, adds any matches to the local state
const dispatcher = (data: string[][]) => {
const matches = data[0];
let newColumnsWithMeta: fieldNameMetaStore = {};
let numberOfResults = 0;
matches.forEach((match) => {
if (match in columnsWithMeta) {
newColumnsWithMeta[match] = columnsWithMeta[match];
numberOfResults++;
}
});
setFilteredColumnsWithMeta(newColumnsWithMeta);
searchFilterEvent(numberOfResults);
};
// uFuzzy search
const search = (needle: string) => {
fuzzySearch(Object.keys(columnsWithMeta), needle, dispatcher);
};
// Debounce fuzzy search
const debouncedSearch = debounce(search, 500);
// onChange handler for search input
const onSearchInputChange = (e: React.FormEvent<HTMLInputElement>) => {
const value = e.currentTarget?.value;
if (value) {
debouncedSearch(value);
} else {
// If the search input is empty, reset the local search state.
setFilteredColumnsWithMeta(undefined);
}
};
const sidebarWidth = 220;
const totalWidth = props.width;
const tableWidth = totalWidth - sidebarWidth;
const styles = getStyles(props.theme, height, sidebarWidth);
return (
<div className={styles.wrapper}>
<section className={styles.sidebar}>
<LogsColumnSearch onChange={onSearchInputChange} />
<LogsTableMultiSelect
toggleColumn={toggleColumn}
filteredColumnsWithMeta={filteredColumnsWithMeta}
columnsWithMeta={columnsWithMeta}
/>
</section>
<LogsTable
onClickFilterLabel={props.onClickFilterLabel}
onClickFilterOutLabel={props.onClickFilterOutLabel}
logsSortOrder={props.logsSortOrder}
range={props.range}
splitOpen={props.splitOpen}
timeZone={props.timeZone}
width={tableWidth}
logsFrames={logsFrames}
columnsWithMeta={columnsWithMeta}
height={height}
/>
</div>
);
}
const normalize = (value: number, total: number): number => {
return Math.ceil((100 * value) / total);
};
function getStyles(theme: GrafanaTheme2, height: number, width: number) {
return {
wrapper: css({
display: 'flex',
}),
sidebar: css({
height: height,
fontSize: theme.typography.pxToRem(11),
overflowY: 'hidden',
width: width,
paddingRight: theme.spacing(1.5),
}),
labelCount: css({}),
checkbox: css({}),
};
}
/**
* from public/app/features/explore/Table/TableContainer.tsx
*/
const getTableHeight = (rowCount: number, hasSubFrames: boolean) => {
if (rowCount === 0) {
return 200;
}
// 600px is pretty small for taller monitors, using the innerHeight minus an arbitrary 500px so the table can be viewed in its entirety without needing to scroll outside the panel to see the top and the bottom
const max = Math.max(window.innerHeight - 500, 600);
const min = Math.max(rowCount * 36, hasSubFrames ? 300 : 0) + 40 + 46;
// tries to estimate table height, with a min of 300 and a max of 600
// if there are multiple tables, there is no min
return Math.min(max, min);
};

View File

@ -0,0 +1,162 @@
import { DataFrame, Field, FieldType } from '@grafana/data/src';
import { DataFrameType } from '../../../../../../packages/grafana-data';
export const getMockLokiFrame = (override?: Partial<DataFrame>) => {
const testDataFrame: DataFrame = {
meta: {
custom: {
frameType: 'LabeledTimeValues',
},
},
fields: [
{
config: {},
name: 'labels',
type: FieldType.other,
typeInfo: {
frame: 'json.RawMessage',
},
values: [
{ app: 'grafana', cluster: 'dev-us-central-0', container: 'hg-plugins' },
{ app: 'grafana', cluster: 'dev-us-central-1', container: 'hg-plugins' },
{ app: 'grafana', cluster: 'dev-us-central-2', container: 'hg-plugins' },
],
} as Field,
{
config: {},
name: 'Time',
type: FieldType.time,
values: ['2019-01-01 10:00:00', '2019-01-01 11:00:00', '2019-01-01 12:00:00'],
},
{
config: {},
name: 'Line',
type: FieldType.string,
values: ['log message 1', 'log message 2', 'log message 3'],
},
{
config: {},
name: 'tsNs',
type: FieldType.string,
values: ['1697561006608165746', '1697560998869868000', '1697561010006578474'],
},
{
config: {},
name: 'id',
type: FieldType.string,
values: ['1697561006608165746_b4cc4b72', '1697560998869868000_eeb96c0f', '1697561010006578474_ad5e2e5a'],
},
],
length: 3,
};
return { ...testDataFrame, ...override };
};
export const getMockLokiFrameDataPlane = (override?: Partial<DataFrame>): DataFrame => {
const testDataFrame: DataFrame = {
meta: {
type: DataFrameType.LogLines,
},
fields: [
{
config: {},
name: 'attributes',
type: FieldType.other,
values: [
{ app: 'grafana', cluster: 'dev-us-central-0', container: 'hg-plugins' },
{ app: 'grafana', cluster: 'dev-us-central-1', container: 'hg-plugins' },
{ app: 'grafana', cluster: 'dev-us-central-2', container: 'hg-plugins' },
],
},
{
config: {},
name: 'timestamp',
type: FieldType.time,
values: ['2019-01-01 10:00:00', '2019-01-01 11:00:00', '2019-01-01 12:00:00'],
},
{
config: {},
name: 'body',
type: FieldType.string,
values: ['log message 1', 'log message 2', 'log message 3'],
},
{
config: {},
name: 'tsNs',
type: FieldType.string,
values: ['1697561006608165746', '1697560998869868000', '1697561010006578474'],
},
{
config: {},
name: 'id',
type: FieldType.string,
values: ['1697561006608165746_b4cc4b72', '1697560998869868000_eeb96c0f', '1697561010006578474_ad5e2e5a'],
},
{
config: {
links: [
{
url: 'http://example.com',
title: 'foo',
},
],
},
name: 'traceID',
type: FieldType.string,
values: ['trace1', 'trace2', 'trace3'],
},
],
length: 3,
};
return { ...testDataFrame, ...override };
};
export const getMockElasticFrame = (override?: Partial<DataFrame>, timestamp = 1697732037084) => {
const testDataFrame: DataFrame = {
meta: {},
fields: [
{
name: '@timestamp',
type: FieldType.time,
values: [timestamp, timestamp + 1000, timestamp + 2000],
config: {},
},
{
name: 'line',
type: FieldType.string,
values: ['log message 1', 'log message 2', 'log message 3'],
config: {},
},
{
name: 'counter',
type: FieldType.string,
values: ['1', '2', '3'],
config: {},
},
{
name: 'level',
type: FieldType.string,
values: ['info', 'info', 'info'],
config: {},
},
{
name: 'id',
type: FieldType.string,
values: ['1', '2', '3'],
config: {},
},
],
length: 3,
};
return { ...testDataFrame, ...override };
};
it('should return a frame', () => {
expect(
getMockLokiFrame({
name: 'test',
})
).toMatchObject({
name: 'test',
});
});

View File

@ -0,0 +1,45 @@
import uFuzzy from '@leeoniya/ufuzzy';
import { debounce as debounceLodash } from 'lodash';
const uf = new uFuzzy({
intraMode: 1,
intraIns: 1,
intraSub: 1,
intraTrn: 1,
intraDel: 1,
});
export function fuzzySearch(haystack: string[], query: string, dispatcher: (data: string[][]) => void) {
const [idxs, info, order] = uf.search(haystack, query, false, 1e5);
let haystackOrder: string[] = [];
let matchesSet: Set<string> = new Set();
if (idxs && order) {
/**
* get the fuzzy matches for hilighting
* @param part
* @param matched
*/
const mark = (part: string, matched: boolean) => {
if (matched) {
matchesSet.add(part);
}
};
// Iterate to create the order of needles(queries) and the matches
for (let i = 0; i < order.length; i++) {
let infoIdx = order[i];
/** Evaluate the match, get the matches for highlighting */
uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark);
/** Get the order */
haystackOrder.push(haystack[info.idx[infoIdx]]);
}
dispatcher([haystackOrder, [...matchesSet]]);
} else if (!query) {
dispatcher([[], []]);
}
}
export const debouncedFuzzySearch = debounceLodash(fuzzySearch, 300);

View File

@ -10,7 +10,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou
import { addListener, ExploreItemState, ExploreQueryParams, useDispatch, useSelector } from 'app/types';
import { changeDatasource } from '../../state/datasource';
import { initializeExplore } from '../../state/explorePane';
import { changePanelsStateAction, initializeExplore } from '../../state/explorePane';
import { clearPanes, splitClose, splitOpen, syncTimesAction } from '../../state/main';
import { runQueries, setQueriesAction } from '../../state/query';
import { selectPanes } from '../../state/selectors';
@ -49,9 +49,14 @@ export function useStateSync(params: ExploreQueryParams) {
// - a pane is opened or closed
// - a query is run
// - range is changed
[splitClose.type, splitOpen.fulfilled.type, runQueries.pending.type, changeRangeAction.type].includes(
action.type
),
// - panel state is updated
[
splitClose.type,
splitOpen.fulfilled.type,
runQueries.pending.type,
changeRangeAction.type,
changePanelsStateAction.type,
].includes(action.type),
effect: async (_, { cancelActiveListeners, delay, getState }) => {
// The following 2 lines will throttle updates to avoid creating history entries when rapid changes
// are committed to the store.
@ -128,6 +133,10 @@ export function useStateSync(params: ExploreQueryParams) {
if (update.queries || update.range) {
dispatch(runQueries({ exploreId }));
}
if (update.panelsState && panelsState) {
dispatch(changePanelsStateAction({ exploreId, panelsState }));
}
});
} else {
// This happens when browser history is used to navigate.

View File

@ -53,7 +53,7 @@ interface ChangePanelsState {
exploreId: string;
panelsState: ExplorePanelsState;
}
const changePanelsStateAction = createAction<ChangePanelsState>('explore/changePanels');
export const changePanelsStateAction = createAction<ChangePanelsState>('explore/changePanels');
export function changePanelState(
exploreId: string,
panel: PreferredVisualisationType,