mirror of
https://github.com/grafana/grafana.git
synced 2025-02-16 18:34:52 -06:00
* 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
516 lines
16 KiB
TypeScript
516 lines
16 KiB
TypeScript
import { render, screen } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import React, { ComponentProps } from 'react';
|
|
|
|
import {
|
|
DataFrame,
|
|
EventBusSrv,
|
|
ExploreLogsPanelState,
|
|
ExplorePanelsState,
|
|
LoadingState,
|
|
LogLevel,
|
|
LogRowModel,
|
|
MutableDataFrame,
|
|
standardTransformersRegistry,
|
|
toUtc,
|
|
} from '@grafana/data';
|
|
import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize';
|
|
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', () => ({
|
|
...jest.requireActual('@grafana/runtime'),
|
|
reportInteraction: (interactionName: string, properties?: Record<string, unknown> | undefined) =>
|
|
reportInteraction(interactionName, properties),
|
|
}));
|
|
|
|
const createAndCopyShortLink = jest.fn();
|
|
jest.mock('app/core/utils/shortLinks', () => ({
|
|
...jest.requireActual('app/core/utils/shortLinks'),
|
|
createAndCopyShortLink: (url: string) => createAndCopyShortLink(url),
|
|
}));
|
|
|
|
jest.mock('app/store/store', () => ({
|
|
getState: jest.fn().mockReturnValue({
|
|
explore: {
|
|
panes: {
|
|
left: {
|
|
datasource: 'id',
|
|
queries: [{ refId: 'A', expr: '', queryType: 'range', datasource: { type: 'loki', uid: 'id' } }],
|
|
range: { raw: { from: 'now-1h', to: 'now' } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
dispatch: jest.fn(),
|
|
}));
|
|
|
|
const changePanelState = jest.fn();
|
|
jest.mock('../state/explorePane', () => ({
|
|
...jest.requireActual('../state/explorePane'),
|
|
changePanelState: (exploreId: string, panel: 'logs', panelState: {} | ExploreLogsPanelState) => {
|
|
return changePanelState(exploreId, panel, panelState);
|
|
},
|
|
}));
|
|
|
|
describe('Logs', () => {
|
|
let originalHref = window.location.href;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(window, 'location', {
|
|
value: {
|
|
href: 'http://localhost:3000/explore?test',
|
|
},
|
|
writable: true,
|
|
});
|
|
});
|
|
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,
|
|
};
|
|
});
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
Object.defineProperty(window, 'location', {
|
|
value: {
|
|
href: originalHref,
|
|
},
|
|
writable: true,
|
|
});
|
|
});
|
|
|
|
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 = dataFrame ?? getMockLokiFrame();
|
|
return (
|
|
<Logs
|
|
exploreId={'left'}
|
|
splitOpen={() => undefined}
|
|
logsVolumeEnabled={true}
|
|
onSetLogsVolumeEnabled={() => null}
|
|
onClickFilterLabel={() => null}
|
|
onClickFilterOutLabel={() => null}
|
|
logsVolumeData={undefined}
|
|
loadLogsVolumeData={() => undefined}
|
|
logRows={logs ?? rows}
|
|
timeZone={'utc'}
|
|
width={50}
|
|
loading={false}
|
|
loadingState={LoadingState.Done}
|
|
absoluteRange={{
|
|
from: toUtc('2019-01-01 10:00:00').valueOf(),
|
|
to: toUtc('2019-01-01 16:00:00').valueOf(),
|
|
}}
|
|
range={{
|
|
from: toUtc('2019-01-01 10:00:00'),
|
|
to: toUtc('2019-01-01 16:00:00'),
|
|
raw: { from: 'now-1h', to: 'now' },
|
|
}}
|
|
addResultsToCache={() => {}}
|
|
onChangeTime={() => {}}
|
|
clearCache={() => {}}
|
|
getFieldLinks={() => {
|
|
return [];
|
|
}}
|
|
eventBus={new EventBusSrv()}
|
|
isFilterLabelActive={jest.fn()}
|
|
logsFrames={[testDataFrame]}
|
|
{...partialProps}
|
|
/>
|
|
);
|
|
};
|
|
const setup = (partialProps?: Partial<ComponentProps<typeof Logs>>, dataFrame?: DataFrame, logs?: LogRowModel[]) => {
|
|
return render(getComponent(partialProps, dataFrame ? dataFrame : getMockLokiFrame(), logs));
|
|
};
|
|
|
|
describe('scrolling behavior', () => {
|
|
let originalInnerHeight: number;
|
|
beforeEach(() => {
|
|
originalInnerHeight = window.innerHeight;
|
|
window.innerHeight = 1000;
|
|
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
|
window.HTMLElement.prototype.scroll = jest.fn();
|
|
});
|
|
afterEach(() => {
|
|
window.innerHeight = originalInnerHeight;
|
|
});
|
|
|
|
describe('when `exploreScrollableLogsContainer` is set', () => {
|
|
let featureToggle: boolean | undefined;
|
|
beforeEach(() => {
|
|
featureToggle = config.featureToggles.exploreScrollableLogsContainer;
|
|
config.featureToggles.exploreScrollableLogsContainer = true;
|
|
});
|
|
afterEach(() => {
|
|
config.featureToggles.exploreScrollableLogsContainer = featureToggle;
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('should call `this.state.logsContainer.scroll`', () => {
|
|
const scrollIntoViewSpy = jest.spyOn(window.HTMLElement.prototype, 'scrollIntoView');
|
|
jest.spyOn(window.HTMLElement.prototype, 'scrollTop', 'get').mockReturnValue(920);
|
|
const scrollSpy = jest.spyOn(window.HTMLElement.prototype, 'scroll');
|
|
|
|
const logs = [];
|
|
for (let i = 0; i < 50; i++) {
|
|
logs.push(makeLog({ uid: `uid${i}`, rowId: `id${i}`, timeEpochMs: i }));
|
|
}
|
|
|
|
setup({ panelState: { logs: { id: 'uid47' } } }, undefined, logs);
|
|
|
|
expect(scrollIntoViewSpy).toBeCalledTimes(1);
|
|
// element.getBoundingClientRect().top will always be 0 for jsdom
|
|
// calc will be `this.state.logsContainer.scrollTop - window.innerHeight / 2` -> 920 - 500 = 420
|
|
expect(scrollSpy).toBeCalledWith({ behavior: 'smooth', top: 420 });
|
|
});
|
|
});
|
|
|
|
describe('when `exploreScrollableLogsContainer` is not set', () => {
|
|
let featureToggle: boolean | undefined;
|
|
beforeEach(() => {
|
|
featureToggle = config.featureToggles.exploreScrollableLogsContainer;
|
|
config.featureToggles.exploreScrollableLogsContainer = false;
|
|
});
|
|
afterEach(() => {
|
|
config.featureToggles.exploreScrollableLogsContainer = featureToggle;
|
|
});
|
|
|
|
it('should call `scrollElement.scroll`', () => {
|
|
const logs = [];
|
|
for (let i = 0; i < 50; i++) {
|
|
logs.push(makeLog({ uid: `uid${i}`, rowId: `id${i}`, timeEpochMs: i }));
|
|
}
|
|
const scrollElementMock = {
|
|
scroll: jest.fn(),
|
|
scrollTop: 920,
|
|
};
|
|
setup(
|
|
{ scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState: { logs: { id: 'uid47' } } },
|
|
undefined,
|
|
logs
|
|
);
|
|
|
|
// element.getBoundingClientRect().top will always be 0 for jsdom
|
|
// calc will be `scrollElement.scrollTop - window.innerHeight / 2` -> 920 - 500 = 420
|
|
expect(scrollElementMock.scroll).toBeCalledWith({ behavior: 'smooth', top: 420 });
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should render logs', () => {
|
|
setup();
|
|
const logsSection = screen.getByTestId('logRows');
|
|
let logRows = logsSection.querySelectorAll('tr');
|
|
expect(logRows.length).toBe(3);
|
|
expect(logRows[0].textContent).toContain('log message 3');
|
|
expect(logRows[2].textContent).toContain('log message 1');
|
|
});
|
|
|
|
it('should render no logs found', () => {
|
|
setup({}, undefined, []);
|
|
|
|
expect(screen.getByText(/no logs found\./i)).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole('button', {
|
|
name: /scan for older logs/i,
|
|
})
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render a load more button', () => {
|
|
const scanningStarted = jest.fn();
|
|
render(
|
|
<Logs
|
|
exploreId={'left'}
|
|
splitOpen={() => undefined}
|
|
logsVolumeEnabled={true}
|
|
onSetLogsVolumeEnabled={() => null}
|
|
onClickFilterLabel={() => null}
|
|
onClickFilterOutLabel={() => null}
|
|
logsVolumeData={undefined}
|
|
loadLogsVolumeData={() => undefined}
|
|
logRows={[]}
|
|
onStartScanning={scanningStarted}
|
|
timeZone={'utc'}
|
|
width={50}
|
|
loading={false}
|
|
loadingState={LoadingState.Done}
|
|
absoluteRange={{
|
|
from: toUtc('2019-01-01 10:00:00').valueOf(),
|
|
to: toUtc('2019-01-01 16:00:00').valueOf(),
|
|
}}
|
|
range={{
|
|
from: toUtc('2019-01-01 10:00:00'),
|
|
to: toUtc('2019-01-01 16:00:00'),
|
|
raw: { from: 'now-1h', to: 'now' },
|
|
}}
|
|
addResultsToCache={() => {}}
|
|
onChangeTime={() => {}}
|
|
clearCache={() => {}}
|
|
getFieldLinks={() => {
|
|
return [];
|
|
}}
|
|
eventBus={new EventBusSrv()}
|
|
isFilterLabelActive={jest.fn()}
|
|
/>
|
|
);
|
|
const button = screen.getByRole('button', {
|
|
name: /scan for older logs/i,
|
|
});
|
|
button.click();
|
|
expect(scanningStarted).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should render a stop scanning button', () => {
|
|
render(
|
|
<Logs
|
|
exploreId={'left'}
|
|
splitOpen={() => undefined}
|
|
logsVolumeEnabled={true}
|
|
onSetLogsVolumeEnabled={() => null}
|
|
onClickFilterLabel={() => null}
|
|
onClickFilterOutLabel={() => null}
|
|
logsVolumeData={undefined}
|
|
loadLogsVolumeData={() => undefined}
|
|
logRows={[]}
|
|
scanning={true}
|
|
timeZone={'utc'}
|
|
width={50}
|
|
loading={false}
|
|
loadingState={LoadingState.Done}
|
|
absoluteRange={{
|
|
from: toUtc('2019-01-01 10:00:00').valueOf(),
|
|
to: toUtc('2019-01-01 16:00:00').valueOf(),
|
|
}}
|
|
range={{
|
|
from: toUtc('2019-01-01 10:00:00'),
|
|
to: toUtc('2019-01-01 16:00:00'),
|
|
raw: { from: 'now-1h', to: 'now' },
|
|
}}
|
|
addResultsToCache={() => {}}
|
|
onChangeTime={() => {}}
|
|
clearCache={() => {}}
|
|
getFieldLinks={() => {
|
|
return [];
|
|
}}
|
|
eventBus={new EventBusSrv()}
|
|
isFilterLabelActive={jest.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(
|
|
screen.getByRole('button', {
|
|
name: /stop scan/i,
|
|
})
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render a stop scanning button', () => {
|
|
const scanningStopped = jest.fn();
|
|
|
|
render(
|
|
<Logs
|
|
exploreId={'left'}
|
|
splitOpen={() => undefined}
|
|
logsVolumeEnabled={true}
|
|
onSetLogsVolumeEnabled={() => null}
|
|
onClickFilterLabel={() => null}
|
|
onClickFilterOutLabel={() => null}
|
|
logsVolumeData={undefined}
|
|
loadLogsVolumeData={() => undefined}
|
|
logRows={[]}
|
|
scanning={true}
|
|
onStopScanning={scanningStopped}
|
|
timeZone={'utc'}
|
|
width={50}
|
|
loading={false}
|
|
loadingState={LoadingState.Done}
|
|
absoluteRange={{
|
|
from: toUtc('2019-01-01 10:00:00').valueOf(),
|
|
to: toUtc('2019-01-01 16:00:00').valueOf(),
|
|
}}
|
|
range={{
|
|
from: toUtc('2019-01-01 10:00:00'),
|
|
to: toUtc('2019-01-01 16:00:00'),
|
|
raw: { from: 'now-1h', to: 'now' },
|
|
}}
|
|
addResultsToCache={() => {}}
|
|
onChangeTime={() => {}}
|
|
clearCache={() => {}}
|
|
getFieldLinks={() => {
|
|
return [];
|
|
}}
|
|
eventBus={new EventBusSrv()}
|
|
isFilterLabelActive={jest.fn()}
|
|
/>
|
|
);
|
|
|
|
const button = screen.getByRole('button', {
|
|
name: /stop scan/i,
|
|
});
|
|
button.click();
|
|
expect(scanningStopped).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should flip the order', async () => {
|
|
setup();
|
|
const oldestFirstSelection = screen.getByLabelText('Oldest first');
|
|
await userEvent.click(oldestFirstSelection);
|
|
const logsSection = screen.getByTestId('logRows');
|
|
let logRows = logsSection.querySelectorAll('tr');
|
|
expect(logRows.length).toBe(3);
|
|
expect(logRows[0].textContent).toContain('log message 1');
|
|
expect(logRows[2].textContent).toContain('log message 3');
|
|
});
|
|
|
|
describe('for permalinking', () => {
|
|
it('should dispatch a `changePanelState` event without the id', () => {
|
|
const panelState = { logs: { id: '1' } };
|
|
const { rerender } = setup({ loading: false, panelState });
|
|
|
|
rerender(getComponent({ loading: true, exploreId: 'right', panelState }));
|
|
rerender(getComponent({ loading: false, exploreId: 'right', panelState }));
|
|
|
|
expect(changePanelState).toHaveBeenCalledWith('right', 'logs', { logs: {} });
|
|
});
|
|
|
|
it('should scroll the scrollElement into view if rows contain id', () => {
|
|
const panelState = { logs: { id: '3' } };
|
|
const scrollElementMock = { scroll: jest.fn() };
|
|
setup({ loading: false, scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState });
|
|
|
|
expect(scrollElementMock.scroll).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not scroll the scrollElement into view if rows does not contain id', () => {
|
|
const panelState = { logs: { id: 'not-included' } };
|
|
const scrollElementMock = { scroll: jest.fn() };
|
|
setup({ loading: false, scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState });
|
|
|
|
expect(scrollElementMock.scroll).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call reportInteraction on permalinkClick', async () => {
|
|
const panelState = { logs: { id: 'not-included' } };
|
|
setup({ loading: false, panelState });
|
|
|
|
const row = screen.getAllByRole('row');
|
|
await userEvent.hover(row[0]);
|
|
|
|
const linkButton = screen.getByLabelText('Copy shortlink');
|
|
await userEvent.click(linkButton);
|
|
|
|
expect(reportInteraction).toHaveBeenCalledWith('grafana_explore_logs_permalink_clicked', {
|
|
datasourceType: 'unknown',
|
|
logRowUid: '1',
|
|
logRowLevel: 'debug',
|
|
});
|
|
});
|
|
|
|
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');
|
|
await userEvent.hover(row[0]);
|
|
|
|
const linkButton = screen.getByLabelText('Copy shortlink');
|
|
await userEvent.click(linkButton);
|
|
|
|
expect(createAndCopyShortLink).toHaveBeenCalledWith(
|
|
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'));
|
|
});
|
|
});
|
|
|
|
describe('with table visualisation', () => {
|
|
let originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation;
|
|
|
|
beforeAll(() => {
|
|
originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation;
|
|
config.featureToggles.logsExploreTableVisualisation = true;
|
|
});
|
|
|
|
afterAll(() => {
|
|
config.featureToggles.logsExploreTableVisualisation = originalVisualisationTypeValue;
|
|
});
|
|
|
|
it('should show visualisation type radio group', () => {
|
|
setup();
|
|
const logsSection = screen.getByRole('radio', { name: 'Show results in table visualisation' });
|
|
expect(logsSection).toBeInTheDocument();
|
|
});
|
|
|
|
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);
|
|
|
|
const table = screen.getByTestId('logRowsTable');
|
|
expect(table).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
const makeLog = (overrides: Partial<LogRowModel>): LogRowModel => {
|
|
const uid = overrides.uid || '1';
|
|
const entry = `log message ${uid}`;
|
|
return {
|
|
uid,
|
|
entryFieldIndex: 0,
|
|
rowIndex: 0,
|
|
dataFrame: new MutableDataFrame(),
|
|
logLevel: LogLevel.debug,
|
|
entry,
|
|
hasAnsi: false,
|
|
hasUnescapedContent: false,
|
|
labels: {},
|
|
raw: entry,
|
|
timeFromNow: '',
|
|
timeEpochMs: 1,
|
|
timeEpochNs: '1000000',
|
|
timeLocal: '',
|
|
timeUtc: '',
|
|
...overrides,
|
|
};
|
|
};
|