mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Logs: Add permalink to log lines (#69464)
* 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>
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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();
|
||||
@@ -55,7 +79,7 @@ describe('Logs', () => {
|
||||
});
|
||||
|
||||
it('should render no logs found', () => {
|
||||
setup([]);
|
||||
setup({}, []);
|
||||
|
||||
expect(screen.getByText(/no logs found\./i)).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -85,6 +92,8 @@ interface Props extends Themeable2 {
|
||||
addResultsToCache: () => void;
|
||||
clearCache: () => void;
|
||||
eventBus: EventBus;
|
||||
panelState?: ExplorePanelsState;
|
||||
scrollElement?: HTMLDivElement;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -50,6 +50,7 @@ interface LogsContainerProps extends PropsFromRedux {
|
||||
onStopScanning: () => void;
|
||||
eventBus: EventBus;
|
||||
splitOpenFn: SplitOpen;
|
||||
scrollElement?: HTMLDivElement;
|
||||
}
|
||||
|
||||
class LogsContainer extends PureComponent<LogsContainerProps> {
|
||||
@@ -144,6 +145,7 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
|
||||
addResultsToCache,
|
||||
clearCache,
|
||||
logsVolume,
|
||||
scrollElement,
|
||||
} = this.props;
|
||||
|
||||
if (!logRows) {
|
||||
@@ -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>
|
||||
</>
|
||||
@@ -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];
|
||||
|
||||
@@ -247,6 +252,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI
|
||||
range,
|
||||
absoluteRange,
|
||||
logsVolume,
|
||||
panelState,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
97
public/app/features/logs/components/LogRow.test.tsx
Normal file
97
public/app/features/logs/components/LogRow.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,11 +38,14 @@ interface Props extends Themeable2 {
|
||||
onClickHideField?: (key: string) => void;
|
||||
onLogRowHover?: (row?: LogRowModel) => void;
|
||||
onOpenContext: (row: LogRowModel, onClose: () => void) => void;
|
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
|
||||
styles: LogRowStyles;
|
||||
permalinkedRowId?: string;
|
||||
scrollIntoView?: (element: HTMLElement) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
showContext: boolean;
|
||||
highlightBackround: boolean;
|
||||
showDetails: boolean;
|
||||
}
|
||||
|
||||
@@ -55,17 +58,23 @@ interface State {
|
||||
*/
|
||||
class UnThemedLogRow extends PureComponent<Props, State> {
|
||||
state: State = {
|
||||
showContext: false,
|
||||
highlightBackround: false,
|
||||
showDetails: false,
|
||||
};
|
||||
logLineRef: React.RefObject<HTMLTableRowElement>;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.logLineRef = React.createRef();
|
||||
}
|
||||
|
||||
// we are debouncing the state change by 3 seconds to highlight the logline after the context closed.
|
||||
debouncedContextClose = debounce(() => {
|
||||
this.setState({ showContext: false });
|
||||
this.setState({ highlightBackround: false });
|
||||
}, 3000);
|
||||
|
||||
onOpenContext = (row: LogRowModel) => {
|
||||
this.setState({ showContext: true });
|
||||
this.setState({ highlightBackround: true });
|
||||
this.props.onOpenContext(row, this.debouncedContextClose);
|
||||
};
|
||||
|
||||
@@ -107,6 +116,32 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.scrollToLogRow(this.state, true);
|
||||
}
|
||||
|
||||
componentDidUpdate(_: Props, prevState: State) {
|
||||
this.scrollToLogRow(prevState);
|
||||
}
|
||||
|
||||
scrollToLogRow = (prevState: State, mounted = false) => {
|
||||
if (this.props.permalinkedRowId !== this.props.row.uid) {
|
||||
// only set the new state if the row is not permalinked anymore or if the component was mounted.
|
||||
if (prevState.highlightBackround || mounted) {
|
||||
this.setState({ highlightBackround: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// at this point this row is the permalinked row, so we need to scroll to it and highlight it if possible.
|
||||
if (this.logLineRef.current && this.props.scrollIntoView) {
|
||||
this.props.scrollIntoView(this.logLineRef.current);
|
||||
}
|
||||
if (!this.state.highlightBackround) {
|
||||
this.setState({ highlightBackround: true });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
getRows,
|
||||
@@ -129,12 +164,16 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
||||
app,
|
||||
styles,
|
||||
} = this.props;
|
||||
const { showDetails, showContext } = this.state;
|
||||
const { showDetails, highlightBackround } = this.state;
|
||||
const levelStyles = getLogLevelStyles(theme, row.logLevel);
|
||||
const { errorMessage, hasError } = checkLogsError(row);
|
||||
const logRowBackground = cx(styles.logsRow, {
|
||||
[styles.errorLogRow]: hasError,
|
||||
[styles.contextBackground]: showContext,
|
||||
[styles.highlightBackground]: highlightBackround,
|
||||
});
|
||||
const logRowDetailsBackground = cx(styles.logsRow, {
|
||||
[styles.errorLogRow]: hasError,
|
||||
[styles.highlightBackground]: highlightBackround && !this.state.showDetails,
|
||||
});
|
||||
|
||||
const processedRow =
|
||||
@@ -145,6 +184,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
ref={this.logLineRef}
|
||||
className={logRowBackground}
|
||||
onClick={this.toggleDetails}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
@@ -187,6 +227,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
prettifyLogMessage={prettifyLogMessage}
|
||||
onOpenContext={this.onOpenContext}
|
||||
onPermalinkClick={this.props.onPermalinkClick}
|
||||
app={app}
|
||||
styles={styles}
|
||||
/>
|
||||
@@ -194,7 +235,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
||||
</tr>
|
||||
{this.state.showDetails && (
|
||||
<LogDetails
|
||||
className={logRowBackground}
|
||||
className={logRowDetailsBackground}
|
||||
showDuplicates={showDuplicates}
|
||||
getFieldLinks={getFieldLinks}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
|
||||
86
public/app/features/logs/components/LogRowMessage.test.tsx
Normal file
86
public/app/features/logs/components/LogRowMessage.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React, { ComponentProps } from 'react';
|
||||
|
||||
import { CoreApp, createTheme, LogLevel, LogRowModel } from '@grafana/data';
|
||||
|
||||
import { LogRowMessage } from './LogRowMessage';
|
||||
import { createLogRow } from './__mocks__/logRow';
|
||||
import { getLogRowStyles } from './getLogRowStyles';
|
||||
|
||||
const setup = (propOverrides?: Partial<ComponentProps<typeof LogRowMessage>>, rowOverrides?: Partial<LogRowModel>) => {
|
||||
const theme = createTheme();
|
||||
const styles = getLogRowStyles(theme);
|
||||
const props: ComponentProps<typeof LogRowMessage> = {
|
||||
wrapLogMessage: false,
|
||||
row: createLogRow({ entry: 'test123', logLevel: LogLevel.error, timeEpochMs: 1546297200000, ...rowOverrides }),
|
||||
onOpenContext: () => {},
|
||||
prettifyLogMessage: false,
|
||||
app: CoreApp.Explore,
|
||||
styles,
|
||||
...(propOverrides || {}),
|
||||
};
|
||||
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<LogRowMessage {...props} />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
return props;
|
||||
};
|
||||
|
||||
describe('LogRowMessage', () => {
|
||||
it('renders row entry', () => {
|
||||
setup();
|
||||
expect(screen.queryByText('test123')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('with show context', () => {
|
||||
it('should show context button', () => {
|
||||
setup({ showContextToggle: () => true });
|
||||
expect(screen.queryByLabelText('Show context')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show context button', () => {
|
||||
setup({ showContextToggle: () => false });
|
||||
expect(screen.queryByLabelText('Show context')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call `onOpenContext` with row on click', async () => {
|
||||
const showContextToggle = jest.fn();
|
||||
const props = setup({ showContextToggle: () => true, onOpenContext: showContextToggle });
|
||||
const button = screen.getByLabelText('Show context');
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(showContextToggle).toHaveBeenCalledWith(props.row);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with permalinking', () => {
|
||||
it('should show permalinking button when no `onPermalinkClick` is defined', () => {
|
||||
setup({ onPermalinkClick: jest.fn() });
|
||||
expect(screen.queryByLabelText('Copy shortlink')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show permalinking button when `onPermalinkClick` is defined', () => {
|
||||
setup();
|
||||
expect(screen.queryByLabelText('Copy shortlink')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call `onPermalinkClick` with row on click', async () => {
|
||||
const permalinkClick = jest.fn();
|
||||
const props = setup({ onPermalinkClick: permalinkClick });
|
||||
const button = screen.getByLabelText('Copy shortlink');
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(permalinkClick).toHaveBeenCalledWith(props.row);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ interface Props {
|
||||
app?: CoreApp;
|
||||
showContextToggle?: (row?: LogRowModel) => boolean;
|
||||
onOpenContext: (row: LogRowModel) => void;
|
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
|
||||
styles: LogRowStyles;
|
||||
}
|
||||
|
||||
@@ -76,7 +77,7 @@ export class LogRowMessage extends PureComponent<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { row, wrapLogMessage, prettifyLogMessage, showContextToggle, styles } = this.props;
|
||||
const { row, wrapLogMessage, prettifyLogMessage, showContextToggle, styles, onPermalinkClick } = this.props;
|
||||
const { hasAnsi, raw } = row;
|
||||
const restructuredEntry = restructureLog(raw, prettifyLogMessage);
|
||||
const shouldShowContextToggle = showContextToggle ? showContextToggle(row) : false;
|
||||
@@ -108,6 +109,7 @@ export class LogRowMessage extends PureComponent<Props> {
|
||||
onClick={this.onShowContextClick}
|
||||
tooltip="Show context"
|
||||
tooltipPlacement="top"
|
||||
aria-label="Show context"
|
||||
/>
|
||||
)}
|
||||
<ClipboardButton
|
||||
@@ -117,9 +119,19 @@ export class LogRowMessage extends PureComponent<Props> {
|
||||
fill="text"
|
||||
size="md"
|
||||
getText={this.getLogText}
|
||||
tooltip="Copy"
|
||||
tooltip="Copy to clipboard"
|
||||
tooltipPlacement="top"
|
||||
/>
|
||||
{onPermalinkClick && row.uid && (
|
||||
<IconButton
|
||||
tooltip="Copy shortlink"
|
||||
aria-label="Copy shortlink"
|
||||
tooltipPlacement="top"
|
||||
size="md"
|
||||
name="share-alt"
|
||||
onClick={() => onPermalinkClick(row)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</>
|
||||
|
||||
@@ -44,6 +44,9 @@ export interface Props extends Themeable2 {
|
||||
onClickHideField?: (key: string) => void;
|
||||
onLogRowHover?: (row?: LogRowModel) => void;
|
||||
onOpenContext?: (row: LogRowModel, onClose: () => void) => void;
|
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
|
||||
permalinkedRowId?: string;
|
||||
scrollIntoView?: (element: HTMLElement) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -139,66 +142,40 @@ class UnThemedLogRows extends PureComponent<Props, State> {
|
||||
// React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
|
||||
const getRows = this.makeGetRows(orderedRows);
|
||||
|
||||
const getLogRowProperties = (row: LogRowModel) => {
|
||||
return {
|
||||
getRows: getRows,
|
||||
row: row,
|
||||
showContextToggle: showContextToggle,
|
||||
showDuplicates: showDuplicates,
|
||||
showLabels: showLabels,
|
||||
showTime: showTime,
|
||||
displayedFields: displayedFields,
|
||||
wrapLogMessage: wrapLogMessage,
|
||||
prettifyLogMessage: prettifyLogMessage,
|
||||
timeZone: timeZone,
|
||||
enableLogDetails: enableLogDetails,
|
||||
onClickFilterLabel: onClickFilterLabel,
|
||||
onClickFilterOutLabel: onClickFilterOutLabel,
|
||||
onClickShowField: onClickShowField,
|
||||
onClickHideField: onClickHideField,
|
||||
getFieldLinks: getFieldLinks,
|
||||
logsSortOrder: logsSortOrder,
|
||||
forceEscape: forceEscape,
|
||||
onOpenContext: this.openContext,
|
||||
onLogRowHover: onLogRowHover,
|
||||
app: app,
|
||||
styles: styles,
|
||||
onPermalinkClick: this.props.onPermalinkClick,
|
||||
scrollIntoView: this.props.scrollIntoView,
|
||||
permalinkedRowId: this.props.permalinkedRowId,
|
||||
};
|
||||
};
|
||||
return (
|
||||
<table className={styles.logsRowsTable}>
|
||||
<tbody>
|
||||
{hasData &&
|
||||
firstRows.map((row, index) => (
|
||||
<LogRow
|
||||
key={row.uid}
|
||||
getRows={getRows}
|
||||
row={row}
|
||||
showContextToggle={showContextToggle}
|
||||
showDuplicates={showDuplicates}
|
||||
showLabels={showLabels}
|
||||
showTime={showTime}
|
||||
displayedFields={displayedFields}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
prettifyLogMessage={prettifyLogMessage}
|
||||
timeZone={timeZone}
|
||||
enableLogDetails={enableLogDetails}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
onClickShowField={onClickShowField}
|
||||
onClickHideField={onClickHideField}
|
||||
getFieldLinks={getFieldLinks}
|
||||
logsSortOrder={logsSortOrder}
|
||||
forceEscape={forceEscape}
|
||||
onOpenContext={this.openContext}
|
||||
onLogRowHover={onLogRowHover}
|
||||
app={app}
|
||||
styles={styles}
|
||||
/>
|
||||
))}
|
||||
{hasData &&
|
||||
renderAll &&
|
||||
lastRows.map((row, index) => (
|
||||
<LogRow
|
||||
key={row.uid}
|
||||
getRows={getRows}
|
||||
row={row}
|
||||
showContextToggle={showContextToggle}
|
||||
showDuplicates={showDuplicates}
|
||||
showLabels={showLabels}
|
||||
showTime={showTime}
|
||||
displayedFields={displayedFields}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
prettifyLogMessage={prettifyLogMessage}
|
||||
timeZone={timeZone}
|
||||
enableLogDetails={enableLogDetails}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
onClickShowField={onClickShowField}
|
||||
onClickHideField={onClickHideField}
|
||||
getFieldLinks={getFieldLinks}
|
||||
logsSortOrder={logsSortOrder}
|
||||
forceEscape={forceEscape}
|
||||
onOpenContext={this.openContext}
|
||||
onLogRowHover={onLogRowHover}
|
||||
app={app}
|
||||
styles={styles}
|
||||
/>
|
||||
))}
|
||||
{hasData && firstRows.map((row) => <LogRow key={row.uid} {...getLogRowProperties(row)} />)}
|
||||
{hasData && renderAll && lastRows.map((row) => <LogRow key={row.uid} {...getLogRowProperties(row)} />)}
|
||||
{hasData && !renderAll && (
|
||||
<tr>
|
||||
<td colSpan={5}>Rendering {orderedRows.length - previewLimit!} rows...</td>
|
||||
|
||||
@@ -74,8 +74,8 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
|
||||
width: 100%;
|
||||
${!scrollableLogsContainer && `margin-bottom: ${theme.spacing(2.25)};`}
|
||||
`,
|
||||
contextBackground: css`
|
||||
background: ${hoverBgColor};
|
||||
highlightBackground: css`
|
||||
background-color: ${tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString()};
|
||||
`,
|
||||
logsRow: css`
|
||||
label: logs-row;
|
||||
@@ -234,6 +234,7 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
|
||||
margin-left: 0px;
|
||||
`,
|
||||
rowMenu: css`
|
||||
label: rowMenu;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
flex-direction: row;
|
||||
@@ -245,10 +246,14 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
|
||||
bottom: auto;
|
||||
background: ${theme.colors.background.primary};
|
||||
box-shadow: ${theme.shadows.z3};
|
||||
padding: ${theme.spacing(0.5, 0.5, 0.5, 1)};
|
||||
padding: ${theme.spacing(0.5, 1, 0.5, 1)};
|
||||
z-index: 100;
|
||||
visibility: hidden;
|
||||
gap: ${theme.spacing(0.5)};
|
||||
|
||||
& > button {
|
||||
margin: 0;
|
||||
}
|
||||
`,
|
||||
logRowMenuCell: css`
|
||||
position: sticky;
|
||||
@@ -286,6 +291,17 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
|
||||
background-color: ${theme.colors.background.primary};
|
||||
}
|
||||
`,
|
||||
visibleRowMenu: css`
|
||||
label: visibleRowMenu;
|
||||
aspect-ratio: 1/1;
|
||||
z-index: 90;
|
||||
`,
|
||||
linkButton: css`
|
||||
label: linkButton;
|
||||
> button {
|
||||
padding-top: ${theme.spacing(0.5)};
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user