From e2a6f9a84928cfb2f958522b1f50fdf3051f51de Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Mon, 7 Apr 2025 16:38:55 +0200 Subject: [PATCH] Logs: Add new Controls component to Explore (#103401) * ControlledLogRows: create component * Fix imports * ControlledLogRows: handle scroll events * Rename storage key prop * LogListControls: externally control syntax highlighting * ControlledLogRows: add support for level filtering * Logs: implement deduplication from controls * Fix imports * Create feature flag * Use feature flag * LogListControls: add download button * LogsMetaRow: extract download function to logs utils * Filter and download logs * Update tests with new props * LogList: pass logs and logs meta to context * Remove prefix from downloaded file * Update unit tests * LogListControl: update unit tests * Fix type assertion * Fix imports * Formatting * i18n * Fix test * LogListControls: adjust scroll to top styles * LogListContext: control legacy options * LogListControls: add showUniqueLabels and prettifyJSON options * LogListControls: test new controls * Extract translations * Hide old controls by feature flag * LogListControls: update prettify json copy * ControlledLogRows: disable preview * Prettier * LogListControls: Fix test --- .betterer.results | 3 - .../src/types/featureToggles.gen.ts | 5 + pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 17 + public/app/features/explore/Logs/Logs.tsx | 317 ++++++++++-------- .../app/features/explore/Logs/LogsMetaRow.tsx | 113 +------ .../features/inspector/utils/download.test.ts | 7 + .../app/features/inspector/utils/download.ts | 5 +- .../logs/components/ControlledLogRows.tsx | 140 ++++++++ .../features/logs/components/LogRows.test.tsx | 2 + .../logs/components/panel/LogLine.test.tsx | 1 + .../components/panel/LogLineMenu.test.tsx | 1 + .../logs/components/panel/LogList.tsx | 8 +- .../components/panel/LogListContext.test.tsx | 1 + .../logs/components/panel/LogListContext.tsx | 86 ++++- .../components/panel/LogListControls.test.tsx | 84 ++++- .../logs/components/panel/LogListControls.tsx | 121 ++++++- .../panel/__mocks__/LogListContext.tsx | 9 + public/app/features/logs/utils.ts | 78 +++++ public/locales/en-US/grafana.json | 10 + 22 files changed, 766 insertions(+), 255 deletions(-) create mode 100644 public/app/features/logs/components/ControlledLogRows.tsx diff --git a/.betterer.results b/.betterer.results index 2246730eb2c..3612d3f9a76 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2345,9 +2345,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] ], - "public/app/features/explore/Logs/LogsMetaRow.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/explore/Logs/LogsSamplePanel.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 3767b20b941..bb7cf35fac7 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1059,4 +1059,9 @@ export interface FeatureToggles { * Enables localization for plugins */ localizationForPlugins?: boolean; + /** + * Enables a control component for the logs panel in Explore + * @default false + */ + logsPanelControls?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index dd1fcec4ccf..a978456034e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1823,6 +1823,14 @@ var ( Owner: grafanaPluginsPlatformSquad, FrontendOnly: false, }, + { + Name: "logsPanelControls", + Description: "Enables a control component for the logs panel in Explore", + Stage: FeatureStagePrivatePreview, + FrontendOnly: true, + Owner: grafanaObservabilityLogsSquad, + Expression: "false", + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index a4647e9592e..9af21c53f96 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -239,3 +239,4 @@ alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true xrayApplicationSignals,experimental,@grafana/aws-datasources,false,false,true multiTenantTempCredentials,experimental,@grafana/aws-datasources,false,false,false localizationForPlugins,experimental,@grafana/plugins-platform-backend,false,false,false +logsPanelControls,privatePreview,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index f29a8f5ee22..19496aea6d0 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -966,4 +966,8 @@ const ( // FlagLocalizationForPlugins // Enables localization for plugins FlagLocalizationForPlugins = "localizationForPlugins" + + // FlagLogsPanelControls + // Enables a control component for the logs panel in Explore + FlagLogsPanelControls = "logsPanelControls" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 68d80761b25..4de4884bddb 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1856,6 +1856,23 @@ "expression": "true" } }, + { + "metadata": { + "name": "logsPanelControls", + "resourceVersion": "1743772342343", + "creationTimestamp": "2025-04-04T13:11:29Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-04-04 13:12:22.343052 +0000 UTC" + } + }, + "spec": { + "description": "Enables a control component for the logs panel in Explore", + "stage": "privatePreview", + "codeowner": "@grafana/observability-logs", + "frontend": true, + "expression": "false" + } + }, { "metadata": { "name": "lokiExperimentalStreaming", diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 783f08b8a5f..f7e5f4d09e2 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -52,10 +52,12 @@ import { mapMouseEventToMode } from '@grafana/ui/internal'; import { Trans, t } from 'app/core/internationalization'; import store from 'app/core/store'; import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks'; +import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows'; import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll'; import { LogRows } from 'app/features/logs/components/LogRows'; import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; import { LogList, LogListControlOptions } from 'app/features/logs/components/panel/LogList'; +import { isDedupStrategy, isLogsSortOrder } from 'app/features/logs/components/panel/LogListContext'; import { LogLevelColor, dedupLogRows, filterLogLevels } from 'app/features/logs/logsModel'; import { getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils'; import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen'; @@ -774,7 +776,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const onLogOptionsChange = useCallback( (option: keyof LogListControlOptions, value: string | string[] | boolean) => { - if (option === 'sortOrder' && (value === LogsSortOrder.Ascending || value === LogsSortOrder.Descending)) { + if (option === 'sortOrder' && isLogsSortOrder(value)) { sortOrderChanged(value); } else if (option === 'filterLevels' && Array.isArray(value)) { if (value.length === 0) { @@ -797,6 +799,8 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { toggleLegendRef.current?.(removesLevel, SeriesVisibilityChangeMode.AppendToSelection); setHiddenLogLevels([...hiddenLogLevels, removesLevel]); } + } else if (option === 'dedupStrategy' && isDedupStrategy(value)) { + setDedupStrategy(value); } }, [hiddenLogLevels, sortOrderChanged] @@ -886,107 +890,109 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { loadingState={loading ? LoadingState.Loading : LoadingState.Done} >
- {visualisationType !== 'table' && !config.featureToggles.newLogsPanel && ( -
- - - + + - - - + + + - - - + + + - - - + + + - - - ({ - label: capitalize(dedupType), - value: dedupType, - description: LogsDedupDescription[dedupType], - }))} - value={dedupStrategy} - onChange={onChangeDedup} - className={styles.radioButtons} - /> - - + > + + + + ({ + label: capitalize(dedupType), + value: dedupType, + description: LogsDedupDescription[dedupType], + }))} + value={dedupStrategy} + onChange={onChangeDedup} + className={styles.radioButtons} + /> + + -
- - - +
+ + + +
-
- )} + )}
= (props: Props) => { />
)} - {visualisationType === 'logs' && hasData && !config.featureToggles.newLogsPanel && ( + {config.featureToggles.logsPanelControls && visualisationType === 'logs' && hasData && ( +
+ +
+ )} + {!config.featureToggles.logsPanelControls && visualisationType === 'logs' && hasData && ( <>
= (props: Props) => { onUnpinLine={onPinToContentOutlineClick} onPinLine={onPinToContentOutlineClick} pinLineButtonTooltipTitle={pinLineButtonTooltipTitle} - renderPreview />
@@ -1089,40 +1138,44 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { /> )} - {visualisationType === 'logs' && hasData && config.featureToggles.newLogsPanel && ( -
- {logsContainerRef.current && ( - - )} -
- )} + {!config.featureToggles.logsPanelControls && + visualisationType === 'logs' && + hasData && + config.featureToggles.newLogsPanel && ( +
+ {logsContainerRef.current && ( + + )} +
+ )} {!loading && !hasData && !scanning && (
diff --git a/public/app/features/explore/Logs/LogsMetaRow.tsx b/public/app/features/explore/Logs/LogsMetaRow.tsx index 610432cbaf8..96aa09fa933 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.tsx @@ -1,36 +1,16 @@ import { css } from '@emotion/css'; -import saveAs from 'file-saver'; import { memo } from 'react'; -import { lastValueFrom, map, Observable } from 'rxjs'; -import { - LogsDedupStrategy, - LogsMetaItem, - LogsMetaKind, - LogRowModel, - CoreApp, - dateTimeFormat, - transformDataFrame, - DataTransformerConfig, - CustomTransformOperator, - Labels, - DataFrame, - Field, - getTimeField, - dateTime, -} from '@grafana/data'; +import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { Button, Dropdown, Menu, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; -import { downloadDataFrameAsCsv, downloadLogsModelAsTxt } from '../../inspector/utils/download'; import { LogLabels, LogLabelsList } from '../../logs/components/LogLabels'; import { MAX_CHARACTERS } from '../../logs/components/LogRowMessage'; -import { logRowsToReadableJson } from '../../logs/utils'; +import { DownloadFormat, downloadLogs } from '../../logs/utils'; import { MetaInfoText, MetaItemProps } from '../MetaInfoText'; -import { getLogsExtractFields } from './LogsTable'; - const getStyles = () => ({ metaContainer: css({ flex: 1, @@ -51,12 +31,6 @@ export type Props = { clearDetectedFields: () => void; }; -enum DownloadFormat { - Text = 'text', - Json = 'json', - CSV = 'csv', -} - export const LogsMetaRow = memo( ({ meta, @@ -71,52 +45,6 @@ export const LogsMetaRow = memo( }: Props) => { const style = useStyles2(getStyles); - const downloadLogs = async (format: DownloadFormat) => { - reportInteraction('grafana_logs_download_logs_clicked', { - app: CoreApp.Explore, - format, - area: 'logs-meta-row', - }); - - switch (format) { - case DownloadFormat.Text: - downloadLogsModelAsTxt({ meta, rows: logRows }, 'Explore'); - break; - case DownloadFormat.Json: - const jsonLogs = logRowsToReadableJson(logRows); - const blob = new Blob([JSON.stringify(jsonLogs)], { - type: 'application/json;charset=utf-8', - }); - const fileName = `Explore-logs-${dateTimeFormat(new Date())}.json`; - saveAs(blob, fileName); - break; - case DownloadFormat.CSV: - const dataFrameMap = new Map(); - logRows.forEach((row) => { - if (row.dataFrame?.refId && !dataFrameMap.has(row.dataFrame?.refId)) { - dataFrameMap.set(row.dataFrame?.refId, row.dataFrame); - } - }); - dataFrameMap.forEach(async (dataFrame) => { - const transforms: Array = getLogsExtractFields(dataFrame); - transforms.push( - { - id: 'organize', - options: { - excludeByName: { - ['labels']: true, - ['labelTypes']: true, - }, - }, - }, - addISODateTransformation - ); - const transformedDataFrame = await lastValueFrom(transformDataFrame(transforms, [dataFrame])); - downloadDataFrameAsCsv(transformedDataFrame[0], `Explore-logs-${dataFrame.refId}`); - }); - } - }; - const logsMetaItem: Array = [...meta]; // Add deduplication info @@ -154,6 +82,15 @@ export const LogsMetaRow = memo( ); } + function download(format: DownloadFormat) { + reportInteraction('grafana_logs_download_logs_clicked', { + app: CoreApp.Explore, + format, + area: 'logs-meta-row', + }); + downloadLogs(format, logRows, meta); + } + // Add unescaped content info if (hasUnescapedContent) { logsMetaItem.push({ @@ -173,11 +110,11 @@ export const LogsMetaRow = memo( const downloadMenu = ( {/* eslint-disable-next-line @grafana/no-untranslated-strings */} - downloadLogs(DownloadFormat.Text)} /> + download(DownloadFormat.Text)} /> {/* eslint-disable-next-line @grafana/no-untranslated-strings */} - downloadLogs(DownloadFormat.Json)} /> + download(DownloadFormat.Json)} /> {/* eslint-disable-next-line @grafana/no-untranslated-strings */} - downloadLogs(DownloadFormat.CSV)} /> + download(DownloadFormat.CSV)} /> ); return ( @@ -192,7 +129,7 @@ export const LogsMetaRow = memo( }; })} /> - {!config.exploreHideLogsDownload && ( + {!config.featureToggles.logsPanelControls && !config.exploreHideLogsDownload && ( Download @@ -221,23 +158,3 @@ function renderMetaItem(value: string | number | Labels, kind: LogsMetaKind) { console.error(`Meta type ${typeof value} ${value} not recognized.`); return <>; } - -const addISODateTransformation: CustomTransformOperator = () => (source: Observable) => { - return source.pipe( - map((data: DataFrame[]) => { - return data.map((frame: DataFrame) => { - const timeField = getTimeField(frame); - return { - ...frame, - fields: [ - { - name: 'Date', - values: timeField.timeField?.values.map((v) => dateTime(v).toISOString()), - } as Field, - ...frame.fields, - ], - }; - }); - }) - ); -}; diff --git a/public/app/features/inspector/utils/download.test.ts b/public/app/features/inspector/utils/download.test.ts index e03ee569d79..9b98d629307 100644 --- a/public/app/features/inspector/utils/download.test.ts +++ b/public/app/features/inspector/utils/download.test.ts @@ -117,6 +117,13 @@ describe('inspector download', () => { expect(text).toEqual(expected); expect(filename).toEqual(`${title}-logs-${dateTimeFormat(1400000000000)}.txt`); }); + + it('should, when title is empty, resolve in %s', async () => { + downloadLogsModelAsTxt({ meta: [], rows: [] }); + const call = (saveAs as unknown as jest.Mock).mock.calls[0]; + const filename = call[1]; + expect(filename).toEqual(`Logs-${dateTimeFormat(1400000000000)}.txt`); + }); }); }); diff --git a/public/app/features/inspector/utils/download.ts b/public/app/features/inspector/utils/download.ts index 6a8a5b6a6d2..d5eeadb95ca 100644 --- a/public/app/features/inspector/utils/download.ts +++ b/public/app/features/inspector/utils/download.ts @@ -21,7 +21,7 @@ import { transformToZipkin } from '../../../plugins/datasource/zipkin/utils/tran * @param {(Pick)} logsModel * @param {string} title */ -export function downloadLogsModelAsTxt(logsModel: Pick, title: string) { +export function downloadLogsModelAsTxt(logsModel: Pick, title = '') { let textToDownload = ''; logsModel.meta?.forEach((metaItem) => { @@ -38,8 +38,7 @@ export function downloadLogsModelAsTxt(logsModel: Pick { + loading: boolean; + logsMeta?: LogsMetaItem[]; + loadMoreLogs?: (range: AbsoluteTimeRange) => void; + logOptionsStorageKey?: string; + onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void; + range: TimeRange; +} + +type LogRowsComponentProps = Omit< + ControlledLogRowsProps, + 'app' | 'dedupStrategy' | 'showLabels' | 'showTime' | 'logsSortOrder' | 'prettifyLogMessage' | 'wrapLogMessage' +>; + +export const ControlledLogRows = ({ + deduplicatedRows, + dedupStrategy, + showLabels, + showTime, + logsMeta, + logOptionsStorageKey, + logsSortOrder, + prettifyLogMessage, + onLogOptionsChange, + wrapLogMessage, + ...rest +}: ControlledLogRowsProps) => { + return ( + + + + ); +}; + +const LogRowsComponent = ({ loading, loadMoreLogs, deduplicatedRows = [], range, ...rest }: LogRowsComponentProps) => { + const { app, dedupStrategy, filterLevels, 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 ( +
+
+ + + +
+ +
+ ); +}; + +function handleScrollToEvent(event: ScrollToLogsEvent, scrollElement: HTMLDivElement | null) { + if (event.payload.scrollTo === 'top') { + scrollElement?.scrollTo(0, 0); + } else if (scrollElement) { + scrollElement.scrollTo(0, scrollElement.scrollHeight); + } +} + +const styles = { + scrollableLogRows: css({ + overflowY: 'scroll', + width: '100%', + maxHeight: '75vh', + }), + logRows: css({ + overflowX: 'scroll', + overflowY: 'visible', + width: '100%', + }), + logRowsContainer: css({ + display: 'flex', + }), +}; diff --git a/public/app/features/logs/components/LogRows.test.tsx b/public/app/features/logs/components/LogRows.test.tsx index 3c84d4406fd..9f2df7f23c9 100644 --- a/public/app/features/logs/components/LogRows.test.tsx +++ b/public/app/features/logs/components/LogRows.test.tsx @@ -18,7 +18,9 @@ jest.mock('../utils', () => ({ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), config: { + ...jest.requireActual('@grafana/runtime').config, featureToggles: { + ...jest.requireActual('@grafana/runtime').config.featureToggles, logRowsPopoverMenu: true, }, }, diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 952f2f2a3cf..be0b99a2791 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -18,6 +18,7 @@ const contextProps = { app: CoreApp.Unknown, dedupStrategy: LogsDedupStrategy.exact, displayedFields: [], + logs: [], showControls: false, showTime: false, sortOrder: LogsSortOrder.Ascending, diff --git a/public/app/features/logs/components/panel/LogLineMenu.test.tsx b/public/app/features/logs/components/panel/LogLineMenu.test.tsx index 1eebfd1a3a9..7ea55f2a28e 100644 --- a/public/app/features/logs/components/panel/LogLineMenu.test.tsx +++ b/public/app/features/logs/components/panel/LogLineMenu.test.tsx @@ -18,6 +18,7 @@ const contextProps = { app: CoreApp.Unknown, dedupStrategy: LogsDedupStrategy.exact, displayedFields: [], + logs: [], showControls: false, showTime: false, sortOrder: LogsSortOrder.Ascending, diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 4c3a81cf1d4..366293af4ae 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -15,7 +15,9 @@ import { LogLevel, LogRowModel, LogsDedupStrategy, + LogsMetaItem, LogsSortOrder, + store, TimeRange, } from '@grafana/data'; import { PopoverContent, useTheme2 } from '@grafana/ui'; @@ -53,6 +55,7 @@ interface Props { loadMore?: (range: AbsoluteTimeRange) => void; logOptionsStorageKey?: string; logs: LogRowModel[]; + logsMeta?: LogsMetaItem[]; logSupportsContext?: (row: LogRowModel) => boolean; onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void; onLogLineHover?: (row?: LogRowModel) => void; @@ -94,6 +97,7 @@ export const LogList = ({ loadMore, logOptionsStorageKey, logs, + logsMeta, logSupportsContext, onLogOptionsChange, onLogLineHover, @@ -106,7 +110,7 @@ export const LogList = ({ showControls, showTime, sortOrder, - syntaxHighlighting, + syntaxHighlighting = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true) : true, timeRange, timeZone, wrapLogMessage, @@ -118,6 +122,8 @@ export const LogList = ({ displayedFields={displayedFields} filterLevels={filterLevels} getRowContextQuery={getRowContextQuery} + logs={logs} + logsMeta={logsMeta} logOptionsStorageKey={logOptionsStorageKey} logSupportsContext={logSupportsContext} onLogOptionsChange={onLogOptionsChange} diff --git a/public/app/features/logs/components/panel/LogListContext.test.tsx b/public/app/features/logs/components/panel/LogListContext.test.tsx index 6244b7a66f8..1e64ba77ad3 100644 --- a/public/app/features/logs/components/panel/LogListContext.test.tsx +++ b/public/app/features/logs/components/panel/LogListContext.test.tsx @@ -9,6 +9,7 @@ import { defaultProps } from './__mocks__/LogListContext'; const log = createLogLine({ rowId: 'yep' }); const value = { ...defaultProps, + downloadLogs: jest.fn(), getRowContextQuery: jest.fn(), logSupportsContext: jest.fn(), onPermalinkClick: jest.fn(), diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index d90559d782a..38de4bfab75 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -9,20 +9,34 @@ import { useState, } from 'react'; -import { CoreApp, LogLevel, LogRowModel, LogsDedupStrategy, LogsSortOrder, shallowCompare, store } from '@grafana/data'; +import { + CoreApp, + LogLevel, + LogRowModel, + LogsDedupStrategy, + LogsMetaItem, + LogsSortOrder, + shallowCompare, + store, +} from '@grafana/data'; import { PopoverContent } from '@grafana/ui'; +import { DownloadFormat, downloadLogs as download } from '../../utils'; + import { GetRowContextQueryFn } from './LogLineMenu'; -export interface LogListContextData extends Omit { +export interface LogListContextData extends Omit { + downloadLogs: (format: DownloadFormat) => void; filterLevels: LogLevel[]; setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; setDisplayedFields: (displayedFields: string[]) => void; setFilterLevels: (filterLevels: LogLevel[]) => void; setLogListState: Dispatch>; setPinnedLogs: (pinnedlogs: string[]) => void; + setPrettifyJSON: (prettifyJSON: boolean) => void; setSyntaxHighlighting: (syntaxHighlighting: boolean) => void; setShowTime: (showTime: boolean) => void; + setShowUniqueLabels: (showUniqueLabels: boolean) => void; setSortOrder: (sortOrder: LogsSortOrder) => void; setWrapLogMessage: (showTime: boolean) => void; } @@ -31,13 +45,16 @@ export const LogListContext = createContext({ app: CoreApp.Unknown, dedupStrategy: LogsDedupStrategy.none, displayedFields: [], + downloadLogs: () => {}, filterLevels: [], setDedupStrategy: () => {}, setDisplayedFields: () => {}, setFilterLevels: () => {}, setLogListState: () => {}, setPinnedLogs: () => {}, + setPrettifyJSON: () => {}, setShowTime: () => {}, + setShowUniqueLabels: () => {}, setSortOrder: () => {}, setSyntaxHighlighting: () => {}, setWrapLogMessage: () => {}, @@ -67,6 +84,8 @@ export type LogListState = Pick< | 'displayedFields' | 'filterLevels' | 'pinnedLogs' + | 'prettifyJSON' + | 'showUniqueLabels' | 'showTime' | 'sortOrder' | 'syntaxHighlighting' @@ -80,6 +99,8 @@ export interface Props { displayedFields: string[]; filterLevels?: LogLevel[]; getRowContextQuery?: GetRowContextQueryFn; + logs: LogRowModel[]; + logsMeta?: LogsMetaItem[]; logOptionsStorageKey?: string; logSupportsContext?: (row: LogRowModel) => boolean; onLogOptionsChange?: (option: keyof LogListState, value: string | boolean | string[]) => void; @@ -90,7 +111,9 @@ export interface Props { onUnpinLine?: (row: LogRowModel) => void; pinLineButtonTooltipTitle?: PopoverContent; pinnedLogs?: string[]; + prettifyJSON?: boolean; showControls: boolean; + showUniqueLabels?: boolean; showTime: boolean; sortOrder: LogsSortOrder; syntaxHighlighting?: boolean; @@ -103,6 +126,8 @@ export const LogListContextProvider = ({ dedupStrategy, displayedFields, getRowContextQuery, + logs, + logsMeta, logOptionsStorageKey, filterLevels, logSupportsContext, @@ -114,10 +139,12 @@ export const LogListContextProvider = ({ onUnpinLine, pinLineButtonTooltipTitle, pinnedLogs, + prettifyJSON, showControls, showTime, + showUniqueLabels, sortOrder, - syntaxHighlighting = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true) : true, + syntaxHighlighting, wrapLogMessage, }: Props) => { const [logListState, setLogListState] = useState({ @@ -126,7 +153,9 @@ export const LogListContextProvider = ({ filterLevels: filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []), pinnedLogs, + prettifyJSON, showTime, + showUniqueLabels, sortOrder, syntaxHighlighting, wrapLogMessage, @@ -219,6 +248,28 @@ export const LogListContextProvider = ({ [logListState, logOptionsStorageKey, onLogOptionsChange] ); + const setShowUniqueLabels = useCallback( + (showUniqueLabels: boolean) => { + setLogListState({ ...logListState, showUniqueLabels }); + onLogOptionsChange?.('showUniqueLabels', showUniqueLabels); + if (logOptionsStorageKey) { + store.set(`${logOptionsStorageKey}.showLabels`, showUniqueLabels); + } + }, + [logListState, logOptionsStorageKey, onLogOptionsChange] + ); + + const setPrettifyJSON = useCallback( + (prettifyJSON: boolean) => { + setLogListState({ ...logListState, prettifyJSON }); + onLogOptionsChange?.('prettifyJSON', prettifyJSON); + if (logOptionsStorageKey) { + store.set(`${logOptionsStorageKey}.prettifyLogMessage`, prettifyJSON); + } + }, + [logListState, logOptionsStorageKey, onLogOptionsChange] + ); + const setSyntaxHighlighting = useCallback( (syntaxHighlighting: boolean) => { setLogListState({ ...logListState, syntaxHighlighting }); @@ -252,12 +303,24 @@ export const LogListContextProvider = ({ [logListState, logOptionsStorageKey, onLogOptionsChange] ); + const downloadLogs = useCallback( + (format: DownloadFormat) => { + const filteredLogs = + logListState.filterLevels.length === 0 + ? logs + : logs.filter((log) => logListState.filterLevels.includes(log.logLevel)); + download(format, filteredLogs, logsMeta); + }, + [logListState.filterLevels, logs, logsMeta] + ); + return ( ); }; + +export function isLogsSortOrder(value: unknown): value is LogsSortOrder { + return value === LogsSortOrder.Ascending || value === LogsSortOrder.Descending; +} + +export function isDedupStrategy(value: unknown): value is LogsDedupStrategy { + return ( + value === LogsDedupStrategy.exact || + value === LogsDedupStrategy.none || + value === LogsDedupStrategy.numbers || + value === LogsDedupStrategy.signature + ); +} diff --git a/public/app/features/logs/components/panel/LogListControls.test.tsx b/public/app/features/logs/components/panel/LogListControls.test.tsx index dce294ce772..ec35cf0fa84 100644 --- a/public/app/features/logs/components/panel/LogListControls.test.tsx +++ b/public/app/features/logs/components/panel/LogListControls.test.tsx @@ -1,16 +1,22 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { CoreApp, EventBusSrv, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; +import { CoreApp, EventBusSrv, LogLevel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; + +import { downloadLogs } from '../../utils'; +import { createLogRow } from '../__mocks__/logRow'; import { LogListContextProvider } from './LogListContext'; import { LogListControls } from './LogListControls'; import { ScrollToLogsEvent } from './virtualization'; +jest.mock('../../utils'); + const contextProps = { app: CoreApp.Unknown, dedupStrategy: LogsDedupStrategy.exact, displayedFields: [], + logs: [], showControls: true, showTime: false, sortOrder: LogsSortOrder.Ascending, @@ -33,6 +39,18 @@ describe('LogListControls', () => { 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(); + }); + + test('Renders legacy controls', () => { + render( + + + + ); + expect(screen.getByLabelText('Show unique labels')).toBeInTheDocument(); + expect(screen.getByLabelText('Expand JSON logs')).toBeInTheDocument(); }); test.each([CoreApp.Dashboard, CoreApp.PanelEditor, CoreApp.PanelViewer])( @@ -160,4 +178,68 @@ describe('LogListControls', () => { expect(onLogOptionsChange).toHaveBeenCalledTimes(1); expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true); }); + + test('Controls unique labels', async () => { + const { rerender } = render( + + + + ); + await userEvent.click(screen.getByLabelText('Show unique labels')); + rerender( + + + + ); + expect(screen.getByLabelText('Hide unique labels')); + }); + + test('Controls Expand JSON logs', async () => { + const { rerender } = render( + + + + ); + await userEvent.click(screen.getByLabelText('Expand JSON logs')); + rerender( + + + + ); + expect(screen.getByLabelText('Collapse JSON logs')); + }); + + test.each([ + ['txt', 'text'], + ['json', 'json'], + ['csv', 'csv'], + ])('Allows to download logs', async (label: string, format: string) => { + jest.mocked(downloadLogs).mockClear(); + render( + + + + ); + await userEvent.click(screen.getByLabelText('Download logs')); + await userEvent.click(await screen.findByText(label)); + expect(downloadLogs).toHaveBeenCalledTimes(1); + expect(downloadLogs).toHaveBeenCalledWith(format, [], undefined); + }); + + test('Allows to download logs filtered logs', async () => { + jest.mocked(downloadLogs).mockClear(); + const log1 = createLogRow({ logLevel: LogLevel.error }); + const log2 = createLogRow({ logLevel: LogLevel.warning }); + const logs = [log1, log2]; + const filteredLogs = [log1]; + + render( + + + + ); + await userEvent.click(screen.getByLabelText('Download logs')); + await userEvent.click(await screen.findByText('txt')); + expect(downloadLogs).toHaveBeenCalledWith('text', filteredLogs, undefined); + }); }); diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx index 4c69aaca522..52587d83fe3 100644 --- a/public/app/features/logs/components/panel/LogListControls.tsx +++ b/public/app/features/logs/components/panel/LogListControls.tsx @@ -11,10 +11,12 @@ import { LogsDedupStrategy, LogsSortOrder, } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { Dropdown, IconButton, Menu, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; +import { DownloadFormat } from '../../utils'; + import { useLogListContext } from './LogListContext'; import { ScrollToLogsEvent } from './virtualization'; @@ -43,14 +45,19 @@ export const LogListControls = ({ eventBus }: Props) => { const { app, dedupStrategy, + downloadLogs, filterLevels, + prettifyJSON, setDedupStrategy, setFilterLevels, + setPrettifyJSON, setShowTime, + setShowUniqueLabels, setSortOrder, setSyntaxHighlighting, setWrapLogMessage, showTime, + showUniqueLabels, sortOrder, syntaxHighlighting, wrapLogMessage, @@ -90,11 +97,18 @@ export const LogListControls = ({ eventBus }: Props) => { const onShowTimestampsClick = useCallback(() => { reportInteraction('logs_log_list_controls_show_time_clicked', { - show_time: showTime, + show_time: !showTime, }); setShowTime(!showTime); }, [setShowTime, showTime]); + const onShowUniqueLabelsClick = useCallback(() => { + reportInteraction('logs_log_list_controls_show_unique_labels_clicked', { + show_unique_labels: showUniqueLabels, + }); + setShowUniqueLabels(!showUniqueLabels); + }, [setShowUniqueLabels, showUniqueLabels]); + const onSortOrderClick = useCallback(() => { reportInteraction('logs_log_list_controls_sort_order_clicked', { order: sortOrder === LogsSortOrder.Ascending ? LogsSortOrder.Descending : LogsSortOrder.Ascending, @@ -102,6 +116,13 @@ export const LogListControls = ({ eventBus }: Props) => { setSortOrder(sortOrder === LogsSortOrder.Ascending ? LogsSortOrder.Descending : LogsSortOrder.Ascending); }, [setSortOrder, sortOrder]); + const onSetPrettifyJSONClick = useCallback(() => { + reportInteraction('logs_log_list_controls_prettify_json_clicked', { + state: !prettifyJSON, + }); + setPrettifyJSON(!prettifyJSON); + }, [prettifyJSON, setPrettifyJSON]); + const onSyntaxHightlightingClick = useCallback(() => { reportInteraction('logs_log_list_controls_syntax_clicked', { state: !syntaxHighlighting, @@ -159,6 +180,26 @@ export const LogListControls = ({ eventBus }: Props) => { [filterLevels, onFilterLevelClick, styles.menuItemActive] ); + const downloadMenu = useMemo( + () => ( + + downloadLogs(DownloadFormat.Text)} + /> + downloadLogs(DownloadFormat.Json)} + /> + downloadLogs(DownloadFormat.CSV)} + /> + + ), + [downloadLogs] + ); + const inDashboard = app === CoreApp.Dashboard || app === CoreApp.PanelEditor || app === CoreApp.PanelViewer; return ( @@ -212,6 +253,20 @@ export const LogListControls = ({ eventBus }: Props) => { } size="lg" /> + {showUniqueLabels !== undefined && ( + + )} { } size="lg" /> - + {prettifyJSON !== undefined && ( + + )} + {syntaxHighlighting !== undefined && ( + + )} + {!config.exploreHideLogsDownload && ( + <> +
+ + + + + )} ) : ( @@ -276,12 +361,20 @@ const getStyles = (theme: GrafanaTheme2) => { scrollToTopButton: css({ margin: 0, marginTop: 'auto', + color: theme.colors.text.secondary, + height: theme.spacing(2), }), controlButton: css({ margin: 0, color: theme.colors.text.secondary, height: theme.spacing(2), }), + divider: css({ + borderTop: `solid 1px ${theme.colors.border.medium}`, + height: 1, + marginTop: theme.spacing(-0.25), + marginBottom: theme.spacing(-1.75), + }), controlButtonActive: css({ margin: 0, color: theme.colors.text.secondary, diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx index 62508f2c357..e514463374c 100644 --- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -8,13 +8,16 @@ export const LogListContext = createContext({ app: CoreApp.Unknown, dedupStrategy: LogsDedupStrategy.none, displayedFields: [], + downloadLogs: () => {}, filterLevels: [], setDedupStrategy: () => {}, setDisplayedFields: () => {}, setFilterLevels: () => {}, setLogListState: () => {}, setPinnedLogs: () => {}, + setPrettifyJSON: () => {}, setShowTime: () => {}, + setShowUniqueLabels: () => {}, setSortOrder: () => {}, setSyntaxHighlighting: () => {}, setWrapLogMessage: () => {}, @@ -45,6 +48,7 @@ export const defaultProps = { filterLevels: [], getRowContextQuery: jest.fn(), logSupportsContext: jest.fn(), + logs: [], onPermalinkClick: jest.fn(), onPinLine: jest.fn(), onOpenContext: jest.fn(), @@ -56,7 +60,9 @@ export const defaultProps = { setLogListState: jest.fn(), setPinnedLogs: jest.fn(), setShowTime: jest.fn(), + setShowUniqueLabels: jest.fn(), setSortOrder: jest.fn(), + setPrettifyJSON: jest.fn(), setSyntaxHighlighting: jest.fn(), setWrapLogMessage: jest.fn(), showControls: true, @@ -90,6 +96,7 @@ export const LogListContextProvider = ({ app, dedupStrategy, displayedFields, + downloadLogs: jest.fn(), filterLevels, getRowContextQuery, logSupportsContext, @@ -103,7 +110,9 @@ export const LogListContextProvider = ({ setFilterLevels: jest.fn(), setLogListState: jest.fn(), setPinnedLogs: jest.fn(), + setPrettifyJSON: jest.fn(), setShowTime: jest.fn(), + setShowUniqueLabels: jest.fn(), setSortOrder: jest.fn(), setSyntaxHighlighting: jest.fn(), setWrapLogMessage: jest.fn(), diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 486c3682ece..16460c350ec 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -1,5 +1,7 @@ +import saveAs from 'file-saver'; import { countBy, chain } from 'lodash'; import { MouseEvent } from 'react'; +import { lastValueFrom, map, Observable } from 'rxjs'; import { LogLevel, @@ -20,9 +22,19 @@ import { locationUtil, urlUtil, dateTime, + dateTimeFormat, + DataTransformerConfig, + CustomTransformOperator, + transformDataFrame, + getTimeField, + Field, + LogsMetaItem, } from '@grafana/data'; import { getConfig } from 'app/core/config'; +import { getLogsExtractFields } from '../explore/Logs/LogsTable'; +import { downloadDataFrameAsCsv, downloadLogsModelAsTxt } from '../inspector/utils/download'; + import { getDataframeFields } from './components/logParser'; import { GetRowContextQueryFn } from './components/panel/LogLineMenu'; @@ -428,3 +440,69 @@ export function enablePopoverMenu() { export function isPopoverMenuDisabled() { return Boolean(localStorage.getItem(POPOVER_STORAGE_KEY)); } + +export enum DownloadFormat { + Text = 'text', + Json = 'json', + CSV = 'csv', +} + +export const downloadLogs = async (format: DownloadFormat, logRows: LogRowModel[], meta?: LogsMetaItem[]) => { + switch (format) { + case DownloadFormat.Text: + downloadLogsModelAsTxt({ meta, rows: logRows }); + break; + case DownloadFormat.Json: + const jsonLogs = logRowsToReadableJson(logRows); + const blob = new Blob([JSON.stringify(jsonLogs)], { + type: 'application/json;charset=utf-8', + }); + const fileName = `Logs-${dateTimeFormat(new Date())}.json`; + saveAs(blob, fileName); + break; + case DownloadFormat.CSV: + const dataFrameMap = new Map(); + logRows.forEach((row) => { + if (row.dataFrame?.refId && !dataFrameMap.has(row.dataFrame?.refId)) { + dataFrameMap.set(row.dataFrame?.refId, row.dataFrame); + } + }); + dataFrameMap.forEach(async (dataFrame) => { + const transforms: Array = getLogsExtractFields(dataFrame); + transforms.push( + { + id: 'organize', + options: { + excludeByName: { + ['labels']: true, + ['labelTypes']: true, + }, + }, + }, + addISODateTransformation + ); + const transformedDataFrame = await lastValueFrom(transformDataFrame(transforms, [dataFrame])); + downloadDataFrameAsCsv(transformedDataFrame[0], `Logs-${dataFrame.refId}`); + }); + } +}; + +const addISODateTransformation: CustomTransformOperator = () => (source: Observable) => { + return source.pipe( + map((data: DataFrame[]) => { + return data.map((frame: DataFrame) => { + const timeField = getTimeField(frame); + const field: Field = { + name: 'Date', + values: timeField.timeField ? timeField.timeField?.values.map((v) => dateTime(v).toISOString()) : [], + type: FieldType.other, + config: {}, + }; + return { + ...frame, + fields: [field, ...frame.fields], + }; + }); + }) + ); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 41ff54b66a9..80cb6e40757 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5128,15 +5128,25 @@ "logs-controls": { "deduplication": "Deduplication", "disable-highlighting": "Disable highlighting", + "disable-prettify-json": "Collapse JSON logs", "display-level": "Display levels", "display-level-all": "All levels", + "download": "Download logs", + "download-logs": { + "csv": "csv", + "json": "json", + "txt": "txt" + }, "enable-highlighting": "Enable highlighting", "hide-timestamps": "Hide timestamps", + "hide-unique-labels": "Hide unique labels", "newest-first": "Newest logs first", "oldest-first": "Oldest logs first", + "prettify-json": "Expand JSON logs", "scroll-bottom": "Scroll to bottom", "scroll-top": "Scroll to top", "show-timestamps": "Show timestamps", + "show-unique-labels": "Show unique labels", "unwrap-lines": "Unwrap lines", "wrap-lines": "Wrap lines" },