From 592c7a0b3cc3a16ee9d7f894e1952de76033e814 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Wed, 23 Apr 2025 16:01:49 +0200 Subject: [PATCH] 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 --- .../logs/panelcfg/x/LogsPanelCfg_types.gen.ts | 3 + .../logs/components/ControlledLogRows.tsx | 239 +++++---- .../logs/components/ControlledLogsTable.tsx | 5 + .../components/panel/LogListControls.test.tsx | 28 +- .../logs/components/panel/LogListControls.tsx | 6 +- public/app/plugins/panel/logs-new/types.ts | 12 +- .../app/plugins/panel/logs/LogsPanel.test.tsx | 489 ++++++++++-------- public/app/plugins/panel/logs/LogsPanel.tsx | 172 ++++-- public/app/plugins/panel/logs/panelcfg.cue | 3 + public/app/plugins/panel/logs/panelcfg.gen.ts | 3 + public/app/plugins/panel/logs/types.test.ts | 19 + public/app/plugins/panel/logs/types.ts | 13 +- 12 files changed, 611 insertions(+), 381 deletions(-) create mode 100644 public/app/plugins/panel/logs/types.test.ts diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index 39ef7f65f58..a73bc96ce5d 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -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; 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; diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index a08a7955f95..98a604233af 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -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 added for Table **/ visualisationType: LogsVisualisationType; - splitOpen: SplitOpen; - panelState: ExploreLogsPanelState | undefined; - updatePanelState: (panelState: Partial) => void; + splitOpen?: SplitOpen; + panelState?: ExploreLogsPanelState; + updatePanelState?: (panelState: Partial) => 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 ( - - {rest.visualisationType === 'logs' && } - {rest.visualisationType === 'table' && } - - ); -}; - -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(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 ( -
- -
( + ( + { + deduplicatedRows, + dedupStrategy, + hasUnescapedContent, + showLabels, + showTime, + logsMeta, + logOptionsStorageKey, + logsSortOrder, + prettifyLogMessage, + onLogOptionsChange, + wrapLogMessage, + ...rest + }: ControlledLogRowsProps, + ref + ) => { + return ( + - - + )} + {rest.visualisationType === 'table' && rest.panelState && rest.updatePanelState && ( + + )} + + ); + } +); + +ControlledLogRows.displayName = 'ControlledLogRows'; + +const LogRowsComponent = forwardRef( + ({ 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(null); + + useEffect(() => { + const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) => + handleScrollToEvent(e, scrollElementRef.current) + ); + return () => subscription.unsubscribe(); + }, [eventBus]); + + useImperativeHandle(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 ( +
+ +
+ - + sortOrder={sortOrder} + > + + +
-
- ); -}; + ); + } +); + +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%', }), }; diff --git a/public/app/features/logs/components/ControlledLogsTable.tsx b/public/app/features/logs/components/ControlledLogsTable.tsx index 5dfb5b5f6a7..2b41364aa82 100644 --- a/public/app/features/logs/components/ControlledLogsTable.tsx +++ b/public/app/features/logs/components/ControlledLogsTable.tsx @@ -32,6 +32,11 @@ export const ControlledLogsTable = ({ const theme = useTheme2(); const styles = getStyles(theme); + if (!splitOpen || !width || !updatePanelState) { + console.error(': Missing required props.'); + return; + } + return (
diff --git a/public/app/features/logs/components/panel/LogListControls.test.tsx b/public/app/features/logs/components/panel/LogListControls.test.tsx index 2471506d8d5..2950f635f4c 100644 --- a/public/app/features/logs/components/panel/LogListControls.test.tsx +++ b/public/app/features/logs/components/panel/LogListControls.test.tsx @@ -49,7 +49,7 @@ describe('LogListControls', () => { test('Renders legacy controls', () => { render( - + ); @@ -76,6 +76,28 @@ describe('LogListControls', () => { } ); + test('Renders a subset of options for plugins', () => { + render( + + + + ); + 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( - + ); await userEvent.click(screen.getByLabelText('Show unique labels')); rerender( - + ); diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx index 23159176475..6bfe64e9bfe 100644 --- a/public/app/features/logs/components/panel/LogListControls.tsx +++ b/public/app/features/logs/components/panel/LogListControls.tsx @@ -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 && ( @@ -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, diff --git a/public/app/plugins/panel/logs-new/types.ts b/public/app/plugins/panel/logs-new/types.ts index 582cc94a1b8..c772acaef60 100644 --- a/public/app/plugins/panel/logs-new/types.ts +++ b/public/app/plugins/panel/logs-new/types.ts @@ -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); } diff --git a/public/app/plugins/panel/logs/LogsPanel.test.tsx b/public/app/plugins/panel/logs/LogsPanel.test.tsx index 7def6ca6cb5..2127c0b7c51 100644 --- a/public/app/plugins/panel/logs/LogsPanel.test.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.test.tsx @@ -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', () => { , ]; - 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) => { +const setup = (propsOverrides?: Partial, showControls = false) => { const props: LogsPanelProps = { ...defaultProps, data: { @@ -827,6 +897,7 @@ const setup = (propsOverrides?: Partial) => { }, options: { ...(propsOverrides?.options || defaultProps.options), + showControls, }, }; diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index be11edd4318..6b8f694122b 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -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 { * * 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); 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} /> )} - setScrollElement(scrollElement)}> -
+ {!showControls ? ( + setScrollElement(scrollElement)}> +
+ {showCommonLabels && !isAscending && renderCommonLabels()} + + + + {showCommonLabels && isAscending && renderCommonLabels()} +
+
+ ) : ( +
{showCommonLabels && !isAscending && renderCommonLabels()} - 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} - > - - + 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()}
- + )} ); }; @@ -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', diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index 341a6aed88c..b1efe883442 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -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?: _ diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index 05df338fe83..74775a0ee61 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -11,6 +11,7 @@ import * as common from '@grafana/schema'; export interface Options { + controlsStorageKey?: string; dedupStrategy: common.LogsDedupStrategy; displayedFields?: Array; 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; diff --git a/public/app/plugins/panel/logs/types.test.ts b/public/app/plugins/panel/logs/types.test.ts new file mode 100644 index 00000000000..a6da9113bd9 --- /dev/null +++ b/public/app/plugins/panel/logs/types.test.ts @@ -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); + }); +}); diff --git a/public/app/plugins/panel/logs/types.ts b/public/app/plugins/panel/logs/types.ts index 758cd49dd88..9019d5750e8 100644 --- a/public/app/plugins/panel/logs/types.ts +++ b/public/app/plugins/panel/logs/types.ts @@ -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); +}