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 = (
);
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]
+ );
+
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"
},