Skip to content

Commit

Permalink
Logs: Add permalink to log lines (#69464)
Browse files Browse the repository at this point in the history
* create explore panel state for logs

* add props to LogRows and unify

* pass properties from explore to logs

* add css

* implement button and scrolling

* export and use `getUrlStateFromPaneState`

* make `scrollIntoView` optional

* change state handling for permalinks

* change link icon

* removed unused state

* add tests for `LogRowMessage`

* remove unused prop

* fix name

* reorg component

* add `LogRow` tests

* add test for `Logs`

* Update public/app/features/logs/components/LogRow.test.tsx

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>

* Update public/app/features/explore/Logs/Logs.test.tsx

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>

* improve types in test

* fix props export in Logs.tsx

* fix props export in LogRowMessage.tsx

* fix props export in LogRow.tsx

* fixed import

* fix theme import

* remove hidden style

* add better test names

* change to `log line` rather logline

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>

* fix tooltips

* remove unused css

---------

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
  • Loading branch information
2 people authored and harisrozajac committed Jun 30, 2023
1 parent 4bbf35d commit ae63c67
Show file tree
Hide file tree
Showing 12 changed files with 419 additions and 75 deletions.
5 changes: 5 additions & 0 deletions packages/grafana-data/src/types/explore.ts
Expand Up @@ -15,12 +15,17 @@ export interface ExploreUrlState<T extends DataQuery = AnyQuery> {

export interface ExplorePanelsState extends Partial<Record<PreferredVisualisationType, {}>> {
trace?: ExploreTracePanelState;
logs?: ExploreLogsPanelState;
}

export interface ExploreTracePanelState {
spanId?: string;
}

export interface ExploreLogsPanelState {
id?: string;
}

export interface SplitOpenOptions<T extends AnyQuery = AnyQuery> {
datasourceUid: string;
/** @deprecated Will be removed in a future version. Use queries instead. */
Expand Down
1 change: 1 addition & 0 deletions public/app/features/explore/Explore.tsx
Expand Up @@ -347,6 +347,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
onStopScanning={this.onStopScanning}
eventBus={this.logsEventBus}
splitOpenFn={this.onSplitOpen('logs')}
scrollElement={this.scrollElement}
/>
);
}
Expand Down
62 changes: 57 additions & 5 deletions public/app/features/explore/Logs/Logs.test.tsx
@@ -1,21 +1,41 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import React, { ComponentProps } from 'react';

import { LoadingState, LogLevel, LogRowModel, MutableDataFrame, toUtc, EventBusSrv } from '@grafana/data';
import {
EventBusSrv,
ExploreLogsPanelState,
LoadingState,
LogLevel,
LogRowModel,
MutableDataFrame,
toUtc,
} from '@grafana/data';
import { ExploreId } from 'app/types';

import { Logs } from './Logs';

const changePanelState = jest.fn();
jest.mock('../state/explorePane', () => ({
...jest.requireActual('../state/explorePane'),
changePanelState: (exploreId: ExploreId, panel: 'logs', panelState: {} | ExploreLogsPanelState) => {
return changePanelState(exploreId, panel, panelState);
},
}));

describe('Logs', () => {
const setup = (logs?: LogRowModel[]) => {
beforeEach(() => {
jest.clearAllMocks();
});

const getComponent = (partialProps?: Partial<ComponentProps<typeof Logs>>, logs?: LogRowModel[]) => {
const rows = [
makeLog({ uid: '1', timeEpochMs: 1 }),
makeLog({ uid: '2', timeEpochMs: 2 }),
makeLog({ uid: '3', timeEpochMs: 3 }),
];

return render(
return (
<Logs
exploreId={ExploreId.left}
splitOpen={() => undefined}
Expand All @@ -41,9 +61,13 @@ describe('Logs', () => {
return [];
}}
eventBus={new EventBusSrv()}
{...partialProps}
/>
);
};
const setup = (partialProps?: Partial<ComponentProps<typeof Logs>>, logs?: LogRowModel[]) => {
return render(getComponent(partialProps, logs));
};

it('should render logs', () => {
setup();
Expand All @@ -55,7 +79,7 @@ describe('Logs', () => {
});

it('should render no logs found', () => {
setup([]);
setup({}, []);

expect(screen.getByText(/no logs found\./i)).toBeInTheDocument();
expect(
Expand Down Expand Up @@ -192,6 +216,34 @@ describe('Logs', () => {
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: ExploreId.right, panelState }));
rerender(getComponent({ loading: false, exploreId: ExploreId.right, panelState }));

expect(changePanelState).toHaveBeenCalledWith(ExploreId.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();
});
});
});

const makeLog = (overrides: Partial<LogRowModel>): LogRowModel => {
Expand Down
51 changes: 51 additions & 0 deletions public/app/features/explore/Logs/Logs.tsx
Expand Up @@ -26,6 +26,9 @@ import {
DataHoverClearEvent,
EventBus,
LogRowContextOptions,
ExplorePanelsState,
serializeStateToUrlParam,
urlUtil,
} from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
Expand All @@ -41,10 +44,14 @@ import {
} from '@grafana/ui';
import { dedupLogRows, filterLogLevels } from 'app/core/logsModel';
import store from 'app/core/store';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { getState, dispatch } from 'app/store/store';
import { ExploreId } from 'app/types/explore';

import { LogRows } from '../../logs/components/LogRows';
import { LogRowContextModal } from '../../logs/components/log-context/LogRowContextModal';
import { getUrlStateFromPaneState } from '../hooks/useStateSync';
import { changePanelState } from '../state/explorePane';

import { LogsMetaRow } from './LogsMetaRow';
import LogsNavigation from './LogsNavigation';
Expand Down Expand Up @@ -85,6 +92,8 @@ interface Props extends Themeable2 {
addResultsToCache: () => void;
clearCache: () => void;
eventBus: EventBus;
panelState?: ExplorePanelsState;
scrollElement?: HTMLDivElement;
}

interface State {
Expand Down Expand Up @@ -158,6 +167,18 @@ class UnthemedLogs extends PureComponent<Props, State> {
}
}

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
delete this.props.panelState.logs.id;
dispatch(
changePanelState(this.props.exploreId, 'logs', {
...this.props.panelState,
})
);
}
}

onLogRowHover = (row?: LogRowModel) => {
if (!row) {
this.props.eventBus.publish(new DataHoverClearEvent());
Expand Down Expand Up @@ -332,6 +353,33 @@ class UnthemedLogs extends PureComponent<Props, State> {
};
};

onPermalinkClick = async (row: LogRowModel) => {
// 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.range = {
from: new Date(this.props.absoluteRange.from).toISOString(),
to: new Date(this.props.absoluteRange.to).toISOString(),
};

// append changed urlState to baseUrl
const serializedState = serializeStateToUrlParam(urlState);
const baseUrl = /.*(?=\/explore)/.exec(`${window.location.href}`)![0];
const url = urlUtil.renderUrl(`${baseUrl}/explore`, { left: serializedState });
await createAndCopyShortLink(url);
};

scrollIntoView = (element: HTMLElement) => {
const { scrollElement } = this.props;

if (scrollElement) {
scrollElement.scroll({
behavior: 'smooth',
top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2,
});
}
};

checkUnescapedContent = memoizeOne((logRows: LogRowModel[]) => {
return !!logRows.some((r) => r.hasUnescapedContent);
});
Expand Down Expand Up @@ -557,6 +605,9 @@ class UnthemedLogs extends PureComponent<Props, State> {
app={CoreApp.Explore}
onLogRowHover={this.onLogRowHover}
onOpenContext={this.onOpenContext}
onPermalinkClick={this.onPermalinkClick}
permalinkedRowId={this.props.panelState?.logs?.id}
scrollIntoView={this.scrollIntoView}
/>
{!loading && !hasData && !scanning && (
<div className={styles.noData}>
Expand Down
6 changes: 6 additions & 0 deletions public/app/features/explore/Logs/LogsContainer.tsx
Expand Up @@ -50,6 +50,7 @@ interface LogsContainerProps extends PropsFromRedux {
onStopScanning: () => void;
eventBus: EventBus;
splitOpenFn: SplitOpen;
scrollElement?: HTMLDivElement;
}

class LogsContainer extends PureComponent<LogsContainerProps> {
Expand Down Expand Up @@ -144,6 +145,7 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
addResultsToCache,
clearCache,
logsVolume,
scrollElement,
} = this.props;

if (!logRows) {
Expand Down Expand Up @@ -206,6 +208,8 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
addResultsToCache={() => addResultsToCache(exploreId)}
clearCache={() => clearCache(exploreId)}
eventBus={this.props.eventBus}
panelState={this.props.panelState}
scrollElement={scrollElement}
/>
</LogsCrossFadeTransition>
</>
Expand All @@ -228,6 +232,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI
absoluteRange,
supplementaryQueries,
} = item;
const panelState = item.panelsState;
const timeZone = getTimeZone(state.user);
const logsVolume = supplementaryQueries[SupplementaryQueryType.LogsVolume];

Expand All @@ -247,6 +252,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI
range,
absoluteRange,
logsVolume,
panelState,
};
}

Expand Down
2 changes: 1 addition & 1 deletion public/app/features/explore/hooks/useStateSync.ts
Expand Up @@ -366,7 +366,7 @@ const urlDiff = (
};
};

function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlState {
export function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlState {
return {
// datasourceInstance should not be undefined anymore here but in case there is some path for it to be undefined
// lets just fallback instead of crashing.
Expand Down
97 changes: 97 additions & 0 deletions public/app/features/logs/components/LogRow.test.tsx
@@ -0,0 +1,97 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { ComponentProps } from 'react';
import tinycolor from 'tinycolor2';

import { CoreApp, createTheme, LogLevel, LogRowModel } from '@grafana/data';

import { LogRow } from './LogRow';
import { createLogRow } from './__mocks__/logRow';
import { getLogRowStyles } from './getLogRowStyles';

const theme = createTheme();
const styles = getLogRowStyles(theme);
const setup = (propOverrides?: Partial<ComponentProps<typeof LogRow>>, rowOverrides?: Partial<LogRowModel>) => {
const props: ComponentProps<typeof LogRow> = {
row: createLogRow({
entry: 'test123',
uid: 'log-row-id',
logLevel: LogLevel.error,
timeEpochMs: 1546297200000,
...rowOverrides,
}),
enableLogDetails: false,
getRows: () => [],
onOpenContext: () => {},
prettifyLogMessage: false,
app: CoreApp.Explore,
showDuplicates: false,
showLabels: false,
showTime: false,
wrapLogMessage: false,
timeZone: 'utc',
styles,
...(propOverrides || {}),
};

const { container } = render(
<table>
<tbody>
<LogRow {...props} />
</tbody>
</table>
);

return { props, container };
};

describe('LogRow', () => {
it('renders row entry', () => {
setup();
expect(screen.queryByText('test123')).toBeInTheDocument();
});

describe('with permalinking', () => {
it('highlights row with same permalink-id', () => {
const { container } = setup({ permalinkedRowId: 'log-row-id' });
const row = container.querySelector('tr');
expect(row).toHaveStyle(
`background-color: ${tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString()}`
);
});

it('does not highlight row details with different permalink-id', async () => {
const { container } = setup({ permalinkedRowId: 'log-row-id', enableLogDetails: true });
const row = container.querySelector('tr');
await userEvent.click(row!);
const allRows = container.querySelectorAll('tr');

expect(row).toHaveStyle(
`background-color: ${tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString()}`
);
expect(allRows[allRows.length - 1]).not.toHaveStyle(
`background-color: ${tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString()}`
);
});

it('not highlights row with different permalink-id', () => {
const { container } = setup({ permalinkedRowId: 'wrong-log-row-id' });
const row = container.querySelector('tr');
expect(row).not.toHaveStyle(
`background-color: ${tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString()}`
);
});

it('calls `scrollIntoView` if permalink matches', () => {
const scrollIntoView = jest.fn();
setup({ permalinkedRowId: 'log-row-id', scrollIntoView });
expect(scrollIntoView).toHaveBeenCalled();
});

it('not calls `scrollIntoView` if permalink does not match', () => {
const scrollIntoView = jest.fn();
setup({ permalinkedRowId: 'wrong-log-row-id', scrollIntoView });
expect(scrollIntoView).not.toHaveBeenCalled();
});
});
});

0 comments on commit ae63c67

Please sign in to comment.