Log Controls: Allow plugins to use Log Controls (#104237)

* Logs Panel: add showControls option

* Make showControls optional

* Logs Panel: expose storage option

* Controlled log rows: allow table props to be possibly undefined

* Logs panel: expose controlled options change callback

* Logs panel: pass new callback

* Logs Panel: pass the correct field callbacks

* LogListControls: disallow unique labels in apps

* LogListControls: allow to filter by unknown level

* LogListControls: fix wrong aria-pressed state

* LogListControls: hide overflow

* ControlledLogRows: make scroll auto

* Controlled Logs Panel: forward scroll ref

* chore: generalize isCoreApp

* Formatting

* LogListControls: update test

* Logs Panel: make sure tests pass with and without controls

* formatting

* Losg Panel: add comments for the new options

* Log list controls: Add comment
This commit is contained in:
Matias Chomicki
2025-04-23 15:01:49 +01:00
committed by GitHub
parent 5c44ad2763
commit 592c7a0b3c
12 changed files with 611 additions and 381 deletions
@@ -13,6 +13,7 @@ import * as common from '@grafana/schema';
export const pluginVersion = "12.0.0-pre";
export interface Options {
controlsStorageKey?: string;
dedupStrategy: common.LogsDedupStrategy;
displayedFields?: Array<string>;
enableInfiniteScrolling?: boolean;
@@ -29,9 +30,11 @@ export interface Options {
onClickFilterString?: unknown;
onClickHideField?: unknown;
onClickShowField?: unknown;
onLogOptionsChange?: unknown;
onNewLogsReceived?: unknown;
prettifyLogMessage: boolean;
showCommonLabels: boolean;
showControls?: boolean;
showLabels: boolean;
showLogContextToggle: boolean;
showTime: boolean;
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, forwardRef, useImperativeHandle } from 'react';
import {
AbsoluteTimeRange,
@@ -35,12 +35,12 @@ export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
/** Props added for Table **/
visualisationType: LogsVisualisationType;
splitOpen: SplitOpen;
panelState: ExploreLogsPanelState | undefined;
updatePanelState: (panelState: Partial<ExploreLogsPanelState>) => void;
splitOpen?: SplitOpen;
panelState?: ExploreLogsPanelState;
updatePanelState?: (panelState: Partial<ExploreLogsPanelState>) => void;
datasourceType?: string;
width: number;
logsTableFrames: DataFrame[] | undefined;
width?: number;
logsTableFrames?: DataFrame[];
}
export type LogRowsComponentProps = Omit<
@@ -48,108 +48,129 @@ export type LogRowsComponentProps = Omit<
'app' | 'dedupStrategy' | 'showLabels' | 'showTime' | 'logsSortOrder' | 'prettifyLogMessage' | 'wrapLogMessage'
>;
export const ControlledLogRows = ({
deduplicatedRows,
dedupStrategy,
hasUnescapedContent,
showLabels,
showTime,
logsMeta,
logOptionsStorageKey,
logsSortOrder,
prettifyLogMessage,
onLogOptionsChange,
wrapLogMessage,
...rest
}: ControlledLogRowsProps) => {
return (
<LogListContextProvider
app={rest.app || CoreApp.Unknown}
displayedFields={[]}
dedupStrategy={dedupStrategy}
hasUnescapedContent={hasUnescapedContent}
logOptionsStorageKey={logOptionsStorageKey}
logs={deduplicatedRows ?? []}
logsMeta={logsMeta}
prettifyJSON={prettifyLogMessage}
showControls
showTime={showTime}
showUniqueLabels={showLabels}
sortOrder={logsSortOrder || LogsSortOrder.Descending}
onLogOptionsChange={onLogOptionsChange}
wrapLogMessage={wrapLogMessage}
>
{rest.visualisationType === 'logs' && <LogRowsComponent {...rest} deduplicatedRows={deduplicatedRows} />}
{rest.visualisationType === 'table' && <ControlledLogsTable {...rest} />}
</LogListContextProvider>
);
};
const LogRowsComponent = ({ loading, loadMoreLogs, deduplicatedRows = [], range, ...rest }: LogRowsComponentProps) => {
const {
app,
dedupStrategy,
filterLevels,
forceEscape,
prettifyJSON,
sortOrder,
showTime,
showUniqueLabels,
wrapLogMessage,
} = useLogListContext();
const eventBus = useMemo(() => new EventBusSrv(), []);
const scrollElementRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) =>
handleScrollToEvent(e, scrollElementRef.current)
);
return () => subscription.unsubscribe();
}, [eventBus]);
const filteredLogs = useMemo(
() =>
filterLevels.length === 0
? deduplicatedRows
: deduplicatedRows.filter((log) => filterLevels.includes(log.logLevel)),
[filterLevels, deduplicatedRows]
);
return (
<div className={styles.logRowsContainer}>
<LogListControls eventBus={eventBus} />
<div
ref={scrollElementRef}
className={config.featureToggles.logsInfiniteScrolling ? styles.scrollableLogRows : styles.logRows}
export const ControlledLogRows = forwardRef<HTMLDivElement | null, ControlledLogRowsProps>(
(
{
deduplicatedRows,
dedupStrategy,
hasUnescapedContent,
showLabels,
showTime,
logsMeta,
logOptionsStorageKey,
logsSortOrder,
prettifyLogMessage,
onLogOptionsChange,
wrapLogMessage,
...rest
}: ControlledLogRowsProps,
ref
) => {
return (
<LogListContextProvider
app={rest.app || CoreApp.Unknown}
displayedFields={[]}
dedupStrategy={dedupStrategy}
hasUnescapedContent={hasUnescapedContent}
logOptionsStorageKey={logOptionsStorageKey}
logs={deduplicatedRows ?? []}
logsMeta={logsMeta}
prettifyJSON={prettifyLogMessage}
showControls
showTime={showTime}
showUniqueLabels={showLabels}
sortOrder={logsSortOrder || LogsSortOrder.Descending}
onLogOptionsChange={onLogOptionsChange}
wrapLogMessage={wrapLogMessage}
>
<InfiniteScroll
loading={loading}
loadMoreLogs={loadMoreLogs}
range={range}
timeZone={rest.timeZone}
rows={filteredLogs}
scrollElement={scrollElementRef.current}
sortOrder={sortOrder}
>
<LogRows
{...rest}
app={app}
dedupStrategy={dedupStrategy}
deduplicatedRows={filteredLogs}
forceEscape={forceEscape}
logRows={filteredLogs}
logsSortOrder={sortOrder}
{rest.visualisationType === 'logs' && (
<LogRowsComponent ref={ref} {...rest} deduplicatedRows={deduplicatedRows} />
)}
{rest.visualisationType === 'table' && rest.panelState && rest.updatePanelState && (
<ControlledLogsTable {...rest} />
)}
</LogListContextProvider>
);
}
);
ControlledLogRows.displayName = 'ControlledLogRows';
const LogRowsComponent = forwardRef<HTMLDivElement | null, LogRowsComponentProps>(
({ loading, loadMoreLogs, deduplicatedRows = [], range, ...rest }: LogRowsComponentProps, ref) => {
const {
app,
dedupStrategy,
filterLevels,
forceEscape,
prettifyJSON,
sortOrder,
showTime,
showUniqueLabels,
wrapLogMessage,
} = useLogListContext();
const eventBus = useMemo(() => new EventBusSrv(), []);
const scrollElementRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) =>
handleScrollToEvent(e, scrollElementRef.current)
);
return () => subscription.unsubscribe();
}, [eventBus]);
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(ref, () => scrollElementRef.current);
const filteredLogs = useMemo(
() =>
filterLevels.length === 0
? deduplicatedRows
: deduplicatedRows.filter((log) => filterLevels.includes(log.logLevel)),
[filterLevels, deduplicatedRows]
);
const scrollElementClassName = useMemo(() => {
if (ref) {
return styles.forwardedScrollableLogRows;
}
return config.featureToggles.logsInfiniteScrolling ? styles.scrollableLogRows : styles.logRows;
}, [ref]);
return (
<div className={styles.logRowsContainer}>
<LogListControls eventBus={eventBus} />
<div ref={scrollElementRef} className={scrollElementClassName}>
<InfiniteScroll
loading={loading}
loadMoreLogs={loadMoreLogs}
range={range}
timeZone={rest.timeZone}
rows={filteredLogs}
scrollElement={scrollElementRef.current}
prettifyLogMessage={Boolean(prettifyJSON)}
showLabels={Boolean(showUniqueLabels)}
showTime={showTime}
wrapLogMessage={wrapLogMessage}
/>
</InfiniteScroll>
sortOrder={sortOrder}
>
<LogRows
{...rest}
app={app}
dedupStrategy={dedupStrategy}
deduplicatedRows={filteredLogs}
forceEscape={forceEscape}
logRows={filteredLogs}
logsSortOrder={sortOrder}
scrollElement={scrollElementRef.current}
prettifyLogMessage={Boolean(prettifyJSON)}
showLabels={Boolean(showUniqueLabels)}
showTime={showTime}
wrapLogMessage={wrapLogMessage}
/>
</InfiniteScroll>
</div>
</div>
</div>
);
};
);
}
);
LogRowsComponent.displayName = 'LogRowsComponent';
function handleScrollToEvent(event: ScrollToLogsEvent, scrollElement: HTMLDivElement | null) {
if (event.payload.scrollTo === 'top') {
@@ -161,10 +182,15 @@ function handleScrollToEvent(event: ScrollToLogsEvent, scrollElement: HTMLDivEle
const styles = {
scrollableLogRows: css({
overflowY: 'scroll',
overflowY: 'auto',
width: '100%',
maxHeight: '75vh',
}),
forwardedScrollableLogRows: css({
overflowY: 'auto',
width: '100%',
maxHeight: '100%',
}),
logRows: css({
overflowX: 'scroll',
overflowY: 'visible',
@@ -173,5 +199,6 @@ const styles = {
logRowsContainer: css({
display: 'flex',
flexDirection: 'row-reverse',
height: '100%',
}),
};
@@ -32,6 +32,11 @@ export const ControlledLogsTable = ({
const theme = useTheme2();
const styles = getStyles(theme);
if (!splitOpen || !width || !updatePanelState) {
console.error('<ControlledLogsTable>: Missing required props.');
return;
}
return (
<div className={styles.logRowsContainer}>
<LogListControls eventBus={eventBus} visualisationType={visualisationType} />
@@ -49,7 +49,7 @@ describe('LogListControls', () => {
test('Renders legacy controls', () => {
render(
<LogListContextProvider {...contextProps} showUniqueLabels={false} prettifyJSON={false}>
<LogListContextProvider {...contextProps} app={CoreApp.Explore} showUniqueLabels={false} prettifyJSON={false}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
@@ -76,6 +76,28 @@ describe('LogListControls', () => {
}
);
test('Renders a subset of options for plugins', () => {
render(
<LogListContextProvider {...contextProps} app={CoreApp.Unknown}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument();
expect(screen.getByLabelText('Oldest logs first')).toBeInTheDocument();
expect(screen.getByLabelText('Deduplication')).toBeInTheDocument();
expect(screen.getByLabelText('Display levels')).toBeInTheDocument();
expect(screen.getByLabelText('Show timestamps')).toBeInTheDocument();
expect(screen.getByLabelText('Wrap lines')).toBeInTheDocument();
expect(screen.getByLabelText('Enable highlighting')).toBeInTheDocument();
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument();
expect(screen.queryByLabelText('Show unique labels')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Expand JSON logs')).not.toBeInTheDocument();
expect(
screen.queryByLabelText('Fix incorrectly escaped newline and tab sequences in log lines')
).not.toBeInTheDocument();
expect(screen.queryByLabelText('Remove escaping')).not.toBeInTheDocument();
});
test('Allows to scroll', async () => {
const eventBus = new EventBusSrv();
jest.spyOn(eventBus, 'publish');
@@ -185,13 +207,13 @@ describe('LogListControls', () => {
test('Controls unique labels', async () => {
const { rerender } = render(
<LogListContextProvider {...contextProps} showUniqueLabels={false}>
<LogListContextProvider {...contextProps} app={CoreApp.Explore} showUniqueLabels={false}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Show unique labels'));
rerender(
<LogListContextProvider {...contextProps} showUniqueLabels={false}>
<LogListContextProvider {...contextProps} app={CoreApp.Explore} showUniqueLabels={false}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
@@ -33,6 +33,7 @@ const FILTER_LEVELS: LogLevel[] = [
LogLevel.warning,
LogLevel.error,
LogLevel.critical,
LogLevel.unknown,
];
export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) => {
@@ -264,7 +265,8 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
}
size="lg"
/>
{showUniqueLabels !== undefined && (
{/* When this is used in a Plugin context, app is unknown */}
{showUniqueLabels !== undefined && app !== CoreApp.Unknown && (
<IconButton
name="tag-alt"
aria-pressed={showUniqueLabels}
@@ -344,7 +346,6 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
<IconButton
name="download-alt"
className={styles.controlButton}
aria-pressed={wrapLogMessage}
tooltip={t('logs.logs-controls.download', 'Download logs')}
size="lg"
/>
@@ -389,6 +390,7 @@ const getStyles = (theme: GrafanaTheme2) => {
paddingTop: theme.spacing(0.75),
paddingLeft: theme.spacing(1),
borderLeft: `solid 1px ${theme.colors.border.medium}`,
overflow: 'hidden',
}),
scrollToTopButton: css({
margin: 0,
+2 -10
View File
@@ -19,14 +19,6 @@ export function isLogsGrammar(grammar: unknown): grammar is Grammar {
}
export function isCoreApp(app: unknown): app is CoreApp {
return (
app === CoreApp.CloudAlerting ||
app === CoreApp.Correlations ||
app === CoreApp.Dashboard ||
app === CoreApp.Explore ||
app === CoreApp.PanelEditor ||
app === CoreApp.PanelViewer ||
app === CoreApp.UnifiedAlerting ||
app === CoreApp.Unknown
);
const apps = Object.values(CoreApp).map((coreApp) => coreApp.toString());
return typeof app === 'string' && apps.includes(app);
}
+280 -209
View File
@@ -145,10 +145,10 @@ beforeAll(() => {
});
});
describe('LogsPanel', () => {
describe.each([false, true])('LogsPanel with controls = %s', (showControls: boolean) => {
it('publishes an event with the current sort order', async () => {
publishMock.mockClear();
setup();
setup({}, showControls);
await screen.findByText('logline text');
@@ -199,36 +199,52 @@ describe('LogsPanel', () => {
];
it('shows common labels when showCommonLabels is set to true', async () => {
setup({
data: { ...defaultProps.data, series: seriesWithCommonLabels },
options: { ...defaultProps.options, showCommonLabels: true },
});
setup(
{
data: { ...defaultProps.data, series: seriesWithCommonLabels },
options: { ...defaultProps.options, showCommonLabels: true },
},
showControls
);
expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
expect(await screen.findByText(/common_app/i)).toBeInTheDocument();
expect(await screen.findByText(/common_job/i)).toBeInTheDocument();
});
it('shows common labels on top when descending sort order', async () => {
const { container } = setup({
data: { ...defaultProps.data, series: seriesWithCommonLabels },
options: { ...defaultProps.options, showCommonLabels: true, sortOrder: LogsSortOrder.Descending },
});
const { container } = setup(
{
data: { ...defaultProps.data, series: seriesWithCommonLabels },
options: { ...defaultProps.options, showCommonLabels: true, sortOrder: LogsSortOrder.Descending },
},
showControls
);
expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
expect(container.firstChild?.childNodes[0].textContent).toMatch(/^Common labels:app=common_appjob=common_job/);
});
it('shows common labels on bottom when ascending sort order', async () => {
const { container } = setup({
data: { ...defaultProps.data, series: seriesWithCommonLabels },
options: { ...defaultProps.options, showCommonLabels: true, sortOrder: LogsSortOrder.Ascending },
});
const { container } = setup(
{
data: { ...defaultProps.data, series: seriesWithCommonLabels },
options: { ...defaultProps.options, showCommonLabels: true, sortOrder: LogsSortOrder.Ascending },
},
showControls
);
expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
expect(container.firstChild?.childNodes[0].textContent).toMatch(/Common labels:app=common_appjob=common_job$/);
if (!showControls) {
expect(container.firstChild?.childNodes[0].textContent).toMatch(/Common labels:app=common_appjob=common_job$/);
} else {
expect(container.childNodes[0].textContent).toMatch(/Common labels:app=common_appjob=common_job$/);
}
});
it('does not show common labels when showCommonLabels is set to false', async () => {
setup({
data: { ...defaultProps.data, series: seriesWithCommonLabels },
options: { ...defaultProps.options, showCommonLabels: false },
});
setup(
{
data: { ...defaultProps.data, series: seriesWithCommonLabels },
options: { ...defaultProps.options, showCommonLabels: false },
},
showControls
);
await waitFor(async () => {
expect(screen.queryByText(/common labels:/i)).toBeNull();
@@ -261,19 +277,25 @@ describe('LogsPanel', () => {
}),
];
it('shows (no common labels) when showCommonLabels is set to true', async () => {
setup({
data: { ...defaultProps.data, series: seriesWithoutCommonLabels },
options: { ...defaultProps.options, showCommonLabels: true },
});
setup(
{
data: { ...defaultProps.data, series: seriesWithoutCommonLabels },
options: { ...defaultProps.options, showCommonLabels: true },
},
showControls
);
expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
expect(await screen.findByText(/(no common labels)/i)).toBeInTheDocument();
});
it('does not show common labels when showCommonLabels is set to false', async () => {
setup({
data: { ...defaultProps.data, series: seriesWithoutCommonLabels },
options: { ...defaultProps.options, showCommonLabels: false },
});
setup(
{
data: { ...defaultProps.data, series: seriesWithoutCommonLabels },
options: { ...defaultProps.options, showCommonLabels: false },
},
showControls
);
await waitFor(async () => {
expect(screen.queryByText(/common labels:/i)).toBeNull();
expect(screen.queryByText(/(no common labels)/i)).toBeNull();
@@ -322,17 +344,20 @@ describe('LogsPanel', () => {
});
it('should not show the toggle if the datasource does not support show context', async () => {
setup({
data: {
...defaultProps.data,
series,
request: {
...defaultProps.data.request,
app: CoreApp.Dashboard,
targets: [{ refId: 'A', datasource: { uid: 'no-show-context' } }],
setup(
{
data: {
...defaultProps.data,
series,
request: {
...defaultProps.data.request,
app: CoreApp.Dashboard,
targets: [{ refId: 'A', datasource: { uid: 'no-show-context' } }],
},
},
},
});
showControls
);
await waitFor(async () => {
await userEvent.hover(screen.getByText(/logline text/i));
@@ -341,17 +366,20 @@ describe('LogsPanel', () => {
});
it('should show the toggle if the datasource does support show context', async () => {
setup({
data: {
...defaultProps.data,
series,
request: {
...defaultProps.data.request,
app: CoreApp.Dashboard,
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
setup(
{
data: {
...defaultProps.data,
series,
request: {
...defaultProps.data.request,
app: CoreApp.Dashboard,
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
},
},
},
});
showControls
);
await waitFor(async () => {
await userEvent.hover(screen.getByText(/logline text/i));
@@ -360,17 +388,20 @@ describe('LogsPanel', () => {
});
it('should not show the toggle if the datasource does support show context but the app is not Dashboard', async () => {
setup({
data: {
...defaultProps.data,
series,
request: {
...defaultProps.data.request,
app: CoreApp.CloudAlerting,
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
setup(
{
data: {
...defaultProps.data,
series,
request: {
...defaultProps.data.request,
app: CoreApp.CloudAlerting,
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
},
},
},
});
showControls
);
await waitFor(async () => {
await userEvent.hover(screen.getByText(/logline text/i));
@@ -379,17 +410,20 @@ describe('LogsPanel', () => {
});
it('should render the mocked `LogRowContextModal` after click', async () => {
setup({
data: {
...defaultProps.data,
series,
request: {
...defaultProps.data.request,
app: CoreApp.Dashboard,
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
setup(
{
data: {
...defaultProps.data,
series,
request: {
...defaultProps.data.request,
app: CoreApp.Dashboard,
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
},
},
},
});
showControls
);
await waitFor(async () => {
await userEvent.hover(screen.getByText(/logline text/i));
await userEvent.click(screen.getByLabelText(/show context/i));
@@ -398,17 +432,20 @@ describe('LogsPanel', () => {
});
it('should call `getLogRowContext` if the user clicks the show context toggle', async () => {
setup({
data: {
...defaultProps.data,
series,
request: {
...defaultProps.data.request,
app: CoreApp.Dashboard,
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
setup(
{
data: {
...defaultProps.data,
series,
request: {
...defaultProps.data.request,
app: CoreApp.Dashboard,
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
},
},
},
});
showControls
);
await waitFor(async () => {
await userEvent.hover(screen.getByText(/logline text/i));
await userEvent.click(screen.getByLabelText(/show context/i));
@@ -427,17 +464,20 @@ describe('LogsPanel', () => {
<grafanaUI.IconButton name="rss" tooltip="Addon after" aria-label="Addon after" key={1} />,
];
setup({
data: {
...defaultProps.data,
series,
setup(
{
data: {
...defaultProps.data,
series,
},
options: {
...defaultProps.options,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
},
},
options: {
...defaultProps.options,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
},
});
showControls
);
await waitFor(async () => {
await userEvent.hover(screen.getByText(/logline text/i));
@@ -488,12 +528,15 @@ describe('LogsPanel', () => {
});
it('does not rerender without changes', async () => {
const { rerender, props } = setup({
data: {
...defaultProps.data,
series,
const { rerender, props } = setup(
{
data: {
...defaultProps.data,
series,
},
},
});
showControls
);
expect(await screen.findByRole('row')).toBeInTheDocument();
@@ -504,12 +547,15 @@ describe('LogsPanel', () => {
});
it('rerenders when prop changes', async () => {
const { rerender, props } = setup({
data: {
...defaultProps.data,
series,
const { rerender, props } = setup(
{
data: {
...defaultProps.data,
series,
},
},
});
showControls
);
expect(await screen.findByRole('row')).toBeInTheDocument();
@@ -520,12 +566,15 @@ describe('LogsPanel', () => {
});
it('does not re-render when data is loading', async () => {
const { rerender, props } = setup({
data: {
...defaultProps.data,
series,
const { rerender, props } = setup(
{
data: {
...defaultProps.data,
series,
},
},
});
showControls
);
expect(await screen.findByRole('row')).toBeInTheDocument();
@@ -571,26 +620,29 @@ describe('LogsPanel', () => {
const filterForMock = jest.fn();
const filterOutMock = jest.fn();
const isFilterLabelActiveMock = jest.fn();
setup({
data: {
...defaultProps.data,
series,
setup(
{
data: {
...defaultProps.data,
series,
},
options: {
...defaultProps.options,
showLabels: false,
showTime: false,
wrapLogMessage: false,
showCommonLabels: false,
prettifyLogMessage: false,
sortOrder: LogsSortOrder.Descending,
dedupStrategy: LogsDedupStrategy.none,
enableLogDetails: true,
onClickFilterLabel: filterForMock,
onClickFilterOutLabel: filterOutMock,
isFilterLabelActive: isFilterLabelActiveMock,
},
},
options: {
...defaultProps.options,
showLabels: false,
showTime: false,
wrapLogMessage: false,
showCommonLabels: false,
prettifyLogMessage: false,
sortOrder: LogsSortOrder.Descending,
dedupStrategy: LogsDedupStrategy.none,
enableLogDetails: true,
onClickFilterLabel: filterForMock,
onClickFilterOutLabel: filterOutMock,
isFilterLabelActive: isFilterLabelActiveMock,
},
});
showControls
);
expect(await screen.findByRole('row')).toBeInTheDocument();
@@ -610,16 +662,19 @@ describe('LogsPanel', () => {
eventBus: new EventBusSrv(),
});
setup({
data: {
...defaultProps.data,
series,
setup(
{
data: {
...defaultProps.data,
series,
},
options: {
...defaultProps.options,
enableLogDetails: true,
},
},
options: {
...defaultProps.options,
enableLogDetails: true,
},
});
showControls
);
expect(await screen.findByRole('row')).toBeInTheDocument();
@@ -635,16 +690,19 @@ describe('LogsPanel', () => {
onAddAdHocFilter: jest.fn(),
});
setup({
data: {
...defaultProps.data,
series,
setup(
{
data: {
...defaultProps.data,
series,
},
options: {
...defaultProps.options,
enableLogDetails: true,
},
},
options: {
...defaultProps.options,
enableLogDetails: true,
},
});
showControls
);
expect(await screen.findByRole('row')).toBeInTheDocument();
@@ -690,26 +748,29 @@ describe('LogsPanel', () => {
];
it('displays the provided fields instead of the log line', async () => {
setup({
data: {
...defaultProps.data,
series,
setup(
{
data: {
...defaultProps.data,
series,
},
options: {
...defaultProps.options,
showLabels: false,
showTime: false,
wrapLogMessage: false,
showCommonLabels: false,
prettifyLogMessage: false,
sortOrder: LogsSortOrder.Descending,
dedupStrategy: LogsDedupStrategy.none,
enableLogDetails: true,
displayedFields: ['app'],
onClickHideField: undefined,
onClickShowField: undefined,
},
},
options: {
...defaultProps.options,
showLabels: false,
showTime: false,
wrapLogMessage: false,
showCommonLabels: false,
prettifyLogMessage: false,
sortOrder: LogsSortOrder.Descending,
dedupStrategy: LogsDedupStrategy.none,
enableLogDetails: true,
displayedFields: ['app'],
onClickHideField: undefined,
onClickShowField: undefined,
},
});
showControls
);
expect(await screen.findByRole('row')).toBeInTheDocument();
expect(screen.queryByText('logline text')).not.toBeInTheDocument();
@@ -724,25 +785,28 @@ describe('LogsPanel', () => {
});
it('updates the provided fields instead of the log line', async () => {
const { rerender, props } = setup({
data: {
...defaultProps.data,
series,
const { rerender, props } = setup(
{
data: {
...defaultProps.data,
series,
},
options: {
...defaultProps.options,
showLabels: false,
showTime: false,
wrapLogMessage: false,
showCommonLabels: false,
prettifyLogMessage: false,
sortOrder: LogsSortOrder.Descending,
dedupStrategy: LogsDedupStrategy.none,
enableLogDetails: true,
onClickHideField: undefined,
onClickShowField: undefined,
},
},
options: {
...defaultProps.options,
showLabels: false,
showTime: false,
wrapLogMessage: false,
showCommonLabels: false,
prettifyLogMessage: false,
sortOrder: LogsSortOrder.Descending,
dedupStrategy: LogsDedupStrategy.none,
enableLogDetails: true,
onClickHideField: undefined,
onClickShowField: undefined,
},
});
showControls
);
expect(await screen.findByRole('row')).toBeInTheDocument();
expect(screen.getByText('logline text')).toBeInTheDocument();
@@ -753,26 +817,29 @@ describe('LogsPanel', () => {
});
it('enables the behavior with a default implementation', async () => {
setup({
data: {
...defaultProps.data,
series,
setup(
{
data: {
...defaultProps.data,
series,
},
options: {
...defaultProps.options,
showLabels: false,
showTime: false,
wrapLogMessage: false,
showCommonLabels: false,
prettifyLogMessage: false,
sortOrder: LogsSortOrder.Descending,
dedupStrategy: LogsDedupStrategy.none,
enableLogDetails: true,
displayedFields: [],
onClickHideField: undefined,
onClickShowField: undefined,
},
},
options: {
...defaultProps.options,
showLabels: false,
showTime: false,
wrapLogMessage: false,
showCommonLabels: false,
prettifyLogMessage: false,
sortOrder: LogsSortOrder.Descending,
dedupStrategy: LogsDedupStrategy.none,
enableLogDetails: true,
displayedFields: [],
onClickHideField: undefined,
onClickShowField: undefined,
},
});
showControls
);
expect(await screen.findByRole('row')).toBeInTheDocument();
@@ -789,25 +856,28 @@ describe('LogsPanel', () => {
it('overrides the default implementation when the callbacks are provided', async () => {
const onClickShowFieldMock = jest.fn();
setup({
data: {
...defaultProps.data,
series,
setup(
{
data: {
...defaultProps.data,
series,
},
options: {
...defaultProps.options,
showLabels: false,
showTime: false,
wrapLogMessage: false,
showCommonLabels: false,
prettifyLogMessage: false,
sortOrder: LogsSortOrder.Descending,
dedupStrategy: LogsDedupStrategy.none,
enableLogDetails: true,
onClickHideField: jest.fn(),
onClickShowField: onClickShowFieldMock,
},
},
options: {
...defaultProps.options,
showLabels: false,
showTime: false,
wrapLogMessage: false,
showCommonLabels: false,
prettifyLogMessage: false,
sortOrder: LogsSortOrder.Descending,
dedupStrategy: LogsDedupStrategy.none,
enableLogDetails: true,
onClickHideField: jest.fn(),
onClickShowField: onClickShowFieldMock,
},
});
showControls
);
expect(await screen.findByRole('row')).toBeInTheDocument();
@@ -819,7 +889,7 @@ describe('LogsPanel', () => {
});
});
const setup = (propsOverrides?: Partial<LogsPanelProps>) => {
const setup = (propsOverrides?: Partial<LogsPanelProps>, showControls = false) => {
const props: LogsPanelProps = {
...defaultProps,
data: {
@@ -827,6 +897,7 @@ const setup = (propsOverrides?: Partial<LogsPanelProps>) => {
},
options: {
...(propsOverrides?.options || defaultProps.options),
showControls,
},
};
+122 -50
View File
@@ -33,6 +33,7 @@ import {
import { config, getAppEvents } from '@grafana/runtime';
import { ScrollContainer, usePanelContext, useStyles2 } from '@grafana/ui';
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows';
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';
import { PanelDataErrorView } from 'app/features/panel/components/PanelDataErrorView';
@@ -45,6 +46,7 @@ import { COMMON_LABELS, dataFrameToLogsModel, dedupLogRows } from '../../../feat
import {
GetFieldLinksFn,
isCoreApp,
isIsFilterLabelActive,
isOnClickFilterLabel,
isOnClickFilterOutLabel,
@@ -52,6 +54,7 @@ import {
isOnClickFilterString,
isOnClickHideField,
isOnClickShowField,
isOnLogOptionsChange,
isOnNewLogsReceivedType,
isReactNodeArray,
onNewLogsReceivedType,
@@ -93,6 +96,17 @@ interface LogsPanelProps extends PanelProps<Options> {
*
* Callback to be invoked when enableInfiniteScrolling and new logs have been received after an scroll event.
* onNewLogsReceived?: (allLogs: DataFrame[], newLogs: DataFrame[]) => void;
*
* Log Controls props:
*
* Enables a sidebar with controls for scrolling, sort order, deduplication, filtering, timestamps, wrapping, etc.
* showControls?: boolean
*
* If controls are enabled, the component will use this key to store changes to the aforementioned options.
* controlsStorageKey?: string
*
* If controls are enabled, this function is called when a change is made in one of the options from the controls.
* onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
*/
}
interface LogsPermalinkUrlState {
@@ -108,6 +122,8 @@ export const LogsPanel = ({
timeZone,
fieldConfig,
options: {
showControls,
controlsStorageKey,
showLabels,
showTime,
wrapLogMessage,
@@ -121,6 +137,7 @@ export const LogsPanel = ({
onClickFilterOutLabel,
onClickFilterOutString,
onClickFilterString,
onLogOptionsChange,
isFilterLabelActive,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
@@ -145,7 +162,7 @@ export const LogsPanel = ({
// Prevents the scroll position to change when new data from infinite scrolling is received
const keepScrollPositionRef = useRef<null | 'infinite-scroll' | 'user'>(null);
let closeCallback = useRef<() => void>();
const { eventBus, onAddAdHocFilter } = usePanelContext();
const { app, eventBus, onAddAdHocFilter } = usePanelContext();
useEffect(() => {
getAppEvents().publish(
@@ -465,62 +482,114 @@ export const LogsPanel = ({
getLogRowContextUi={getLogRowContextUi}
/>
)}
<ScrollContainer ref={(scrollElement) => setScrollElement(scrollElement)}>
<div onMouseLeave={onLogContainerMouseLeave} className={style.container} ref={logsContainerRef}>
{!showControls ? (
<ScrollContainer ref={(scrollElement) => setScrollElement(scrollElement)}>
<div onMouseLeave={onLogContainerMouseLeave} className={style.container} ref={logsContainerRef}>
{showCommonLabels && !isAscending && renderCommonLabels()}
<InfiniteScroll
loading={infiniteScrolling}
loadMoreLogs={enableInfiniteScrolling ? loadMoreLogs : undefined}
range={data.timeRange}
timeZone={timeZone}
rows={logRows}
scrollElement={scrollElement}
sortOrder={sortOrder}
>
<LogRows
scrollElement={scrollElement}
scrollIntoView={scrollIntoView}
permalinkedRowId={getLogsPanelState()?.logs?.id ?? undefined}
onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined}
logRows={logRows}
showContextToggle={showContextToggle}
deduplicatedRows={deduplicatedRows}
dedupStrategy={dedupStrategy}
showLabels={showLabels}
showTime={showTime}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={sortOrder}
enableLogDetails={enableLogDetails}
previewLimit={isAscending ? logRows.length : undefined}
onLogRowHover={onLogRowHover}
app={isCoreApp(app) ? app : CoreApp.Dashboard}
onOpenContext={onOpenContext}
onClickFilterLabel={
isOnClickFilterLabel(onClickFilterLabel) ? onClickFilterLabel : defaultOnClickFilterLabel
}
onClickFilterOutLabel={
isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel
}
onClickFilterString={isOnClickFilterString(onClickFilterString) ? onClickFilterString : undefined}
onClickFilterOutString={
isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined
}
isFilterLabelActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined}
displayedFields={displayedFields}
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
logRowMenuIconsBefore={isReactNodeArray(logRowMenuIconsBefore) ? logRowMenuIconsBefore : undefined}
logRowMenuIconsAfter={isReactNodeArray(logRowMenuIconsAfter) ? logRowMenuIconsAfter : undefined}
// Ascending order causes scroll to stick to the bottom, so previewing is futile
renderPreview={isAscending ? false : true}
/>
</InfiniteScroll>
{showCommonLabels && isAscending && renderCommonLabels()}
</div>
</ScrollContainer>
) : (
<div onMouseLeave={onLogContainerMouseLeave} className={style.controlledLogsContainer}>
{showCommonLabels && !isAscending && renderCommonLabels()}
<InfiniteScroll
<ControlledLogRows
ref={(scrollElement: HTMLDivElement | null) => setScrollElement(scrollElement)}
visualisationType="logs"
loading={infiniteScrolling}
loadMoreLogs={enableInfiniteScrolling ? loadMoreLogs : undefined}
range={data.timeRange}
logRows={logRows}
deduplicatedRows={deduplicatedRows}
dedupStrategy={dedupStrategy}
onClickFilterLabel={
isOnClickFilterLabel(onClickFilterLabel) ? onClickFilterLabel : defaultOnClickFilterLabel
}
onClickFilterOutLabel={
isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel
}
showContextToggle={showContextToggle}
showLabels={showLabels}
showTime={showTime}
enableLogDetails={enableLogDetails}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
rows={logRows}
scrollElement={scrollElement}
sortOrder={sortOrder}
>
<LogRows
scrollElement={scrollElement}
scrollIntoView={scrollIntoView}
permalinkedRowId={getLogsPanelState()?.logs?.id ?? undefined}
onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined}
logRows={logRows}
showContextToggle={showContextToggle}
deduplicatedRows={deduplicatedRows}
dedupStrategy={dedupStrategy}
showLabels={showLabels}
showTime={showTime}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={sortOrder}
enableLogDetails={enableLogDetails}
previewLimit={isAscending ? logRows.length : undefined}
onLogRowHover={onLogRowHover}
app={CoreApp.Dashboard}
onOpenContext={onOpenContext}
onClickFilterLabel={
isOnClickFilterLabel(onClickFilterLabel) ? onClickFilterLabel : defaultOnClickFilterLabel
}
onClickFilterOutLabel={
isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel
}
onClickFilterString={isOnClickFilterString(onClickFilterString) ? onClickFilterString : undefined}
onClickFilterOutString={
isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined
}
isFilterLabelActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined}
displayedFields={displayedFields}
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
logRowMenuIconsBefore={isReactNodeArray(logRowMenuIconsBefore) ? logRowMenuIconsBefore : undefined}
logRowMenuIconsAfter={isReactNodeArray(logRowMenuIconsAfter) ? logRowMenuIconsAfter : undefined}
// Ascending order causes scroll to stick to the bottom, so previewing is futile
renderPreview={isAscending ? false : true}
/>
</InfiniteScroll>
getFieldLinks={getFieldLinks}
logsSortOrder={sortOrder}
displayedFields={displayedFields}
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
app={isCoreApp(app) ? app : CoreApp.Dashboard}
onLogRowHover={onLogRowHover}
onOpenContext={onOpenContext}
onPermalinkClick={onPermalinkClick}
permalinkedRowId={getLogsPanelState()?.logs?.id ?? undefined}
scrollIntoView={scrollIntoView}
isFilterLabelActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined}
onClickFilterString={isOnClickFilterString(onClickFilterString) ? onClickFilterString : undefined}
onClickFilterOutString={
isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined
}
logRowMenuIconsBefore={isReactNodeArray(logRowMenuIconsBefore) ? logRowMenuIconsBefore : undefined}
logRowMenuIconsAfter={isReactNodeArray(logRowMenuIconsAfter) ? logRowMenuIconsAfter : undefined}
onLogOptionsChange={isOnLogOptionsChange(onLogOptionsChange) ? onLogOptionsChange : undefined}
logOptionsStorageKey={controlsStorageKey}
// Ascending order causes scroll to stick to the bottom, so previewing is futile
renderPreview={false}
/>
{showCommonLabels && isAscending && renderCommonLabels()}
</div>
</ScrollContainer>
)}
</>
);
};
@@ -529,6 +598,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
container: css({
marginBottom: theme.spacing(1.5),
}),
controlledLogsContainer: css({
height: '100%',
}),
labelContainer: css({
margin: theme.spacing(0, 0, 0.5, 0.5),
display: 'flex',
@@ -30,6 +30,8 @@ composableKinds: PanelCfg: {
showCommonLabels: bool
showTime: bool
showLogContextToggle: bool
showControls?: bool
controlsStorageKey?: string
wrapLogMessage: bool
prettifyLogMessage: bool
enableLogDetails: bool
@@ -44,6 +46,7 @@ composableKinds: PanelCfg: {
onClickFilterOutString?: _
onClickShowField?: _
onClickHideField?: _
onLogOptionsChange?: _
logRowMenuIconsBefore?: _
logRowMenuIconsAfter?: _
onNewLogsReceived?: _
@@ -11,6 +11,7 @@
import * as common from '@grafana/schema';
export interface Options {
controlsStorageKey?: string;
dedupStrategy: common.LogsDedupStrategy;
displayedFields?: Array<string>;
enableInfiniteScrolling?: boolean;
@@ -27,9 +28,11 @@ export interface Options {
onClickFilterString?: unknown;
onClickHideField?: unknown;
onClickShowField?: unknown;
onLogOptionsChange?: unknown;
onNewLogsReceived?: unknown;
prettifyLogMessage: boolean;
showCommonLabels: boolean;
showControls?: boolean;
showLabels: boolean;
showLogContextToggle: boolean;
showTime: boolean;
@@ -0,0 +1,19 @@
import { CoreApp } from '@grafana/data';
import { isCoreApp } from './types';
describe('isCoreApp', () => {
test('Identifies core apps', () => {
expect(isCoreApp(CoreApp.Explore)).toBe(true);
expect(isCoreApp(CoreApp.Unknown)).toBe(true);
expect(isCoreApp(CoreApp.PanelEditor)).toBe(true);
expect(isCoreApp(CoreApp.PanelViewer)).toBe(true);
expect(isCoreApp(CoreApp.Dashboard)).toBe(true);
});
test('Identifies non-apps', () => {
expect(isCoreApp('the explore')).toBe(false);
expect(isCoreApp('nope')).toBe(false);
expect(isCoreApp('drilldown')).toBe(false);
});
});
+12 -1
View File
@@ -1,6 +1,7 @@
import React, { ReactNode } from 'react';
import { DataFrame, Field, LinkModel, ScopedVars } from '@grafana/data';
import { CoreApp, DataFrame, Field, LinkModel, ScopedVars } from '@grafana/data';
import { LogListControlOptions } from 'app/features/logs/components/panel/LogList';
export type { Options } from './panelcfg.gen';
@@ -12,6 +13,7 @@ type isFilterLabelActiveType = (key: string, value: string, refId?: string) => P
type isOnClickShowFieldType = (value: string) => void;
type isOnClickHideFieldType = (value: string) => void;
export type onNewLogsReceivedType = (allLogs: DataFrame[], newLogs: DataFrame[]) => void;
type onLogOptionsChangeType = (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
export type GetFieldLinksFn = (
field: Field,
@@ -52,6 +54,15 @@ export function isOnNewLogsReceivedType(callback: unknown): callback is onNewLog
return typeof callback === 'function';
}
export function isOnLogOptionsChange(callback: unknown): callback is onLogOptionsChangeType {
return typeof callback === 'function';
}
export function isReactNodeArray(node: unknown): node is ReactNode[] {
return Array.isArray(node) && node.every(React.isValidElement);
}
export function isCoreApp(app: unknown): app is CoreApp {
const apps = Object.values(CoreApp).map((coreApp) => coreApp.toString());
return typeof app === 'string' && apps.includes(app);
}