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
pull/103553/head
Matias Chomicki 1 month ago committed by GitHub
parent e9ed7223a6
commit e2a6f9a849
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      .betterer.results
  2. 5
      packages/grafana-data/src/types/featureToggles.gen.ts
  3. 8
      pkg/services/featuremgmt/registry.go
  4. 1
      pkg/services/featuremgmt/toggles_gen.csv
  5. 4
      pkg/services/featuremgmt/toggles_gen.go
  6. 17
      pkg/services/featuremgmt/toggles_gen.json
  7. 319
      public/app/features/explore/Logs/Logs.tsx
  8. 113
      public/app/features/explore/Logs/LogsMetaRow.tsx
  9. 7
      public/app/features/inspector/utils/download.test.ts
  10. 5
      public/app/features/inspector/utils/download.ts
  11. 140
      public/app/features/logs/components/ControlledLogRows.tsx
  12. 2
      public/app/features/logs/components/LogRows.test.tsx
  13. 1
      public/app/features/logs/components/panel/LogLine.test.tsx
  14. 1
      public/app/features/logs/components/panel/LogLineMenu.test.tsx
  15. 8
      public/app/features/logs/components/panel/LogList.tsx
  16. 1
      public/app/features/logs/components/panel/LogListContext.test.tsx
  17. 86
      public/app/features/logs/components/panel/LogListContext.tsx
  18. 84
      public/app/features/logs/components/panel/LogListControls.test.tsx
  19. 121
      public/app/features/logs/components/panel/LogListControls.tsx
  20. 9
      public/app/features/logs/components/panel/__mocks__/LogListContext.tsx
  21. 78
      public/app/features/logs/utils.ts
  22. 10
      public/locales/en-US/grafana.json

@ -2345,9 +2345,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans />", "0"]
],

@ -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;
}

@ -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",
},
}
)

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
239 xrayApplicationSignals experimental @grafana/aws-datasources false false true
240 multiTenantTempCredentials experimental @grafana/aws-datasources false false false
241 localizationForPlugins experimental @grafana/plugins-platform-backend false false false
242 logsPanelControls privatePreview @grafana/observability-logs false false true

@ -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"
)

@ -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",

@ -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: 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: 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: Props) => {
loadingState={loading ? LoadingState.Loading : LoadingState.Done}
>
<div className={styles.stickyNavigation}>
{visualisationType !== 'table' && !config.featureToggles.newLogsPanel && (
<div className={styles.logOptions}>
<InlineFieldRow>
<InlineField
label={t('explore.unthemed-logs.label-time', 'Time')}
className={styles.horizontalInlineLabel}
transparent
>
<InlineSwitch
value={showTime}
onChange={onChangeShowTime}
className={styles.horizontalInlineSwitch}
{visualisationType !== 'table' &&
!config.featureToggles.newLogsPanel &&
!config.featureToggles.logsPanelControls && (
<div className={styles.logOptions}>
<InlineFieldRow>
<InlineField
label={t('explore.unthemed-logs.label-time', 'Time')}
className={styles.horizontalInlineLabel}
transparent
id={`show-time_${exploreId}`}
/>
</InlineField>
<InlineField
label={t('explore.unthemed-logs.label-unique-labels', 'Unique labels')}
className={styles.horizontalInlineLabel}
transparent
>
<InlineSwitch
value={showLabels}
onChange={onChangeLabels}
className={styles.horizontalInlineSwitch}
>
<InlineSwitch
value={showTime}
onChange={onChangeShowTime}
className={styles.horizontalInlineSwitch}
transparent
id={`show-time_${exploreId}`}
/>
</InlineField>
<InlineField
label={t('explore.unthemed-logs.label-unique-labels', 'Unique labels')}
className={styles.horizontalInlineLabel}
transparent
id={`unique-labels_${exploreId}`}
/>
</InlineField>
<InlineField
label={t('explore.unthemed-logs.label-wrap-lines', 'Wrap lines')}
className={styles.horizontalInlineLabel}
transparent
>
<InlineSwitch
value={wrapLogMessage}
onChange={onChangeWrapLogMessage}
className={styles.horizontalInlineSwitch}
>
<InlineSwitch
value={showLabels}
onChange={onChangeLabels}
className={styles.horizontalInlineSwitch}
transparent
id={`unique-labels_${exploreId}`}
/>
</InlineField>
<InlineField
label={t('explore.unthemed-logs.label-wrap-lines', 'Wrap lines')}
className={styles.horizontalInlineLabel}
transparent
id={`wrap-lines_${exploreId}`}
/>
</InlineField>
<InlineField
label={t('explore.unthemed-logs.label-prettify-json', 'Prettify JSON')}
className={styles.horizontalInlineLabel}
transparent
>
<InlineSwitch
value={prettifyLogMessage}
onChange={onChangePrettifyLogMessage}
className={styles.horizontalInlineSwitch}
>
<InlineSwitch
value={wrapLogMessage}
onChange={onChangeWrapLogMessage}
className={styles.horizontalInlineSwitch}
transparent
id={`wrap-lines_${exploreId}`}
/>
</InlineField>
<InlineField
label={t('explore.unthemed-logs.label-prettify-json', 'Prettify JSON')}
className={styles.horizontalInlineLabel}
transparent
id={`prettify_${exploreId}`}
/>
</InlineField>
<InlineField
label={t('explore.unthemed-logs.label-deduplication', 'Deduplication')}
className={styles.horizontalInlineLabel}
transparent
>
<RadioButtonGroup
options={DEDUP_OPTIONS.map((dedupType) => ({
label: capitalize(dedupType),
value: dedupType,
description: LogsDedupDescription[dedupType],
}))}
value={dedupStrategy}
onChange={onChangeDedup}
className={styles.radioButtons}
/>
</InlineField>
</InlineFieldRow>
<div>
<InlineField
label={t('explore.unthemed-logs.label-display-results', 'Display results')}
className={styles.horizontalInlineLabel}
transparent
disabled={isFlipping || loading}
>
<RadioButtonGroup
options={[
{
label: 'Newest first',
value: LogsSortOrder.Descending,
description: 'Show results newest to oldest',
},
{
label: 'Oldest first',
value: LogsSortOrder.Ascending,
description: 'Show results oldest to newest',
},
]}
value={logsSortOrder}
onChange={onChangeLogsSortOrder}
className={styles.radioButtons}
/>
</InlineField>
>
<InlineSwitch
value={prettifyLogMessage}
onChange={onChangePrettifyLogMessage}
className={styles.horizontalInlineSwitch}
transparent
id={`prettify_${exploreId}`}
/>
</InlineField>
<InlineField
label={t('explore.unthemed-logs.label-deduplication', 'Deduplication')}
className={styles.horizontalInlineLabel}
transparent
>
<RadioButtonGroup
options={DEDUP_OPTIONS.map((dedupType) => ({
label: capitalize(dedupType),
value: dedupType,
description: LogsDedupDescription[dedupType],
}))}
value={dedupStrategy}
onChange={onChangeDedup}
className={styles.radioButtons}
/>
</InlineField>
</InlineFieldRow>
<div>
<InlineField
label={t('explore.unthemed-logs.label-display-results', 'Display results')}
className={styles.horizontalInlineLabel}
transparent
disabled={isFlipping || loading}
>
<RadioButtonGroup
options={[
{
label: 'Newest first',
value: LogsSortOrder.Descending,
description: 'Show results newest to oldest',
},
{
label: 'Oldest first',
value: LogsSortOrder.Ascending,
description: 'Show results oldest to newest',
},
]}
value={logsSortOrder}
onChange={onChangeLogsSortOrder}
className={styles.radioButtons}
/>
</InlineField>
</div>
</div>
</div>
)}
)}
<div ref={topLogsRef} />
<LogsMetaRow
logRows={logRows}
@ -1020,7 +1026,51 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
/>
</div>
)}
{visualisationType === 'logs' && hasData && !config.featureToggles.newLogsPanel && (
{config.featureToggles.logsPanelControls && visualisationType === 'logs' && hasData && (
<div className={styles.logRowsWrapper} data-testid="logRows">
<ControlledLogRows
loading={loading}
loadMoreLogs={infiniteScrollAvailable ? loadMoreLogs : undefined}
range={props.range}
pinnedLogs={pinnedLogs}
logRows={logRows}
deduplicatedRows={dedupedRows}
dedupStrategy={dedupStrategy}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
showContextToggle={showContextToggle}
getRowContextQuery={getRowContextQuery}
showLabels={showLabels}
showTime={showTime}
enableLogDetails={true}
forceEscape={forceEscape}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
displayedFields={displayedFields}
onClickShowField={showField}
onClickHideField={hideField}
app={CoreApp.Explore}
onLogRowHover={onLogRowHover}
onOpenContext={onOpenContext}
onPermalinkClick={onPermalinkClick}
permalinkedRowId={panelState?.logs?.id}
scrollIntoView={scrollIntoView}
isFilterLabelActive={props.isFilterLabelActive}
onClickFilterString={props.onClickFilterString}
onClickFilterOutString={props.onClickFilterOutString}
onUnpinLine={onPinToContentOutlineClick}
onPinLine={onPinToContentOutlineClick}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
logsMeta={logsMeta}
logOptionsStorageKey={SETTING_KEY_ROOT}
onLogOptionsChange={onLogOptionsChange}
/>
</div>
)}
{!config.featureToggles.logsPanelControls && visualisationType === 'logs' && hasData && (
<>
<div
className={config.featureToggles.logsInfiniteScrolling ? styles.scrollableLogRows : styles.logRows}
@ -1071,7 +1121,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
onUnpinLine={onPinToContentOutlineClick}
onPinLine={onPinToContentOutlineClick}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
renderPreview
/>
</InfiniteScroll>
</div>
@ -1089,40 +1138,44 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
/>
</>
)}
{visualisationType === 'logs' && hasData && config.featureToggles.newLogsPanel && (
<div data-testid="logRows" ref={logsContainerRef} className={styles.logRowsWrapper}>
{logsContainerRef.current && (
<LogList
app={CoreApp.Explore}
containerElement={logsContainerRef.current}
dedupStrategy={dedupStrategy}
displayedFields={displayedFields}
filterLevels={filterLevels}
forceEscape={forceEscape}
getFieldLinks={getFieldLinks}
getRowContextQuery={getRowContextQuery}
loadMore={loadMoreLogs}
logOptionsStorageKey={SETTING_KEY_ROOT}
logs={dedupedRows}
logSupportsContext={showContextToggle}
onLogOptionsChange={onLogOptionsChange}
onLogLineHover={onLogRowHover}
onOpenContext={onOpenContext}
onPermalinkClick={onPermalinkClick}
onPinLine={onPinToContentOutlineClick}
onUnpinLine={onPinToContentOutlineClick}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
pinnedLogs={pinnedLogs}
showControls
showTime={showTime}
sortOrder={logsSortOrder}
timeRange={props.range}
timeZone={timeZone}
wrapLogMessage={wrapLogMessage}
/>
)}
</div>
)}
{!config.featureToggles.logsPanelControls &&
visualisationType === 'logs' &&
hasData &&
config.featureToggles.newLogsPanel && (
<div data-testid="logRows" ref={logsContainerRef} className={styles.logRowsWrapper}>
{logsContainerRef.current && (
<LogList
app={CoreApp.Explore}
containerElement={logsContainerRef.current}
dedupStrategy={dedupStrategy}
displayedFields={displayedFields}
filterLevels={filterLevels}
forceEscape={forceEscape}
getFieldLinks={getFieldLinks}
getRowContextQuery={getRowContextQuery}
loadMore={loadMoreLogs}
logOptionsStorageKey={SETTING_KEY_ROOT}
logs={dedupedRows}
logsMeta={logsMeta}
logSupportsContext={showContextToggle}
onLogOptionsChange={onLogOptionsChange}
onLogLineHover={onLogRowHover}
onOpenContext={onOpenContext}
onPermalinkClick={onPermalinkClick}
onPinLine={onPinToContentOutlineClick}
onUnpinLine={onPinToContentOutlineClick}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
pinnedLogs={pinnedLogs}
showControls
showTime={showTime}
sortOrder={logsSortOrder}
timeRange={props.range}
timeZone={timeZone}
wrapLogMessage={wrapLogMessage}
/>
)}
</div>
)}
{!loading && !hasData && !scanning && (
<div className={styles.noDataWrapper}>
<div className={styles.noData}>

@ -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<string, DataFrame>();
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<DataTransformerConfig | CustomTransformOperator> = 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<LogsMetaItem | MetaItemProps> = [...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 = (
<Menu>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<Menu.Item label="txt" onClick={() => downloadLogs(DownloadFormat.Text)} />
<Menu.Item label="txt" onClick={() => download(DownloadFormat.Text)} />
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<Menu.Item label="json" onClick={() => downloadLogs(DownloadFormat.Json)} />
<Menu.Item label="json" onClick={() => download(DownloadFormat.Json)} />
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<Menu.Item label="csv" onClick={() => downloadLogs(DownloadFormat.CSV)} />
<Menu.Item label="csv" onClick={() => download(DownloadFormat.CSV)} />
</Menu>
);
return (
@ -192,7 +129,7 @@ export const LogsMetaRow = memo(
};
})}
/>
{!config.exploreHideLogsDownload && (
{!config.featureToggles.logsPanelControls && !config.exploreHideLogsDownload && (
<Dropdown overlay={downloadMenu}>
<ToolbarButton isOpen={false} variant="canvas" icon="download-alt">
<Trans i18nKey="explore.logs-meta-row.download">Download</Trans>
@ -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<DataFrame[]>) => {
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,
],
};
});
})
);
};

@ -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`);
});
});
});

@ -21,7 +21,7 @@ import { transformToZipkin } from '../../../plugins/datasource/zipkin/utils/tran
* @param {(Pick<LogsModel, 'meta' | 'rows'>)} logsModel
* @param {string} title
*/
export function downloadLogsModelAsTxt(logsModel: Pick<LogsModel, 'meta' | 'rows'>, title: string) {
export function downloadLogsModelAsTxt(logsModel: Pick<LogsModel, 'meta' | 'rows'>, title = '') {
let textToDownload = '';
logsModel.meta?.forEach((metaItem) => {
@ -38,8 +38,7 @@ export function downloadLogsModelAsTxt(logsModel: Pick<LogsModel, 'meta' | 'rows
const blob = new Blob([textToDownload], {
type: 'text/plain;charset=utf-8',
});
const fileName = `${title}-logs-${dateTimeFormat(new Date())}.txt`;
const fileName = `${title ? `${title}-logs` : 'Logs'}-${dateTimeFormat(new Date())}.txt`;
saveAs(blob, fileName);
}

@ -0,0 +1,140 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useRef } from 'react';
import { AbsoluteTimeRange, CoreApp, EventBusSrv, LogsMetaItem, LogsSortOrder, TimeRange } from '@grafana/data';
import { config } from '@grafana/runtime';
import { InfiniteScroll } from './InfiniteScroll';
import { LogRows, Props } from './LogRows';
import { LogListControlOptions } from './panel/LogList';
import { LogListContextProvider, useLogListContext } from './panel/LogListContext';
import { LogListControls } from './panel/LogListControls';
import { ScrollToLogsEvent } from './panel/virtualization';
interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
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 (
<LogListContextProvider
app={rest.app || CoreApp.Unknown}
displayedFields={[]}
dedupStrategy={dedupStrategy}
logOptionsStorageKey={logOptionsStorageKey}
logs={deduplicatedRows ?? []}
logsMeta={logsMeta}
prettifyJSON={prettifyLogMessage}
showControls
showTime={showTime}
showUniqueLabels={showLabels}
sortOrder={logsSortOrder || LogsSortOrder.Descending}
onLogOptionsChange={onLogOptionsChange}
wrapLogMessage={wrapLogMessage}
>
<LogRowsComponent {...rest} deduplicatedRows={deduplicatedRows} />
</LogListContextProvider>
);
};
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<HTMLDivElement | null>(null);
useEffect(() => {
const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) =>
handleScrollToEvent(e, scrollElementRef.current)
);
return () => subscription.unsubscribe();
}, [eventBus]);
const filteredLogs = useMemo(
() =>
filterLevels.length === 0
? deduplicatedRows
: deduplicatedRows.filter((log) => filterLevels.includes(log.logLevel)),
[filterLevels, deduplicatedRows]
);
return (
<div className={styles.logRowsContainer}>
<div
ref={scrollElementRef}
className={config.featureToggles.logsInfiniteScrolling ? styles.scrollableLogRows : styles.logRows}
>
<InfiniteScroll
loading={loading}
loadMoreLogs={loadMoreLogs}
range={range}
timeZone={rest.timeZone}
rows={filteredLogs}
scrollElement={scrollElementRef.current}
sortOrder={sortOrder}
>
<LogRows
{...rest}
app={app}
dedupStrategy={dedupStrategy}
deduplicatedRows={filteredLogs}
logRows={filteredLogs}
logsSortOrder={sortOrder}
scrollElement={scrollElementRef.current}
prettifyLogMessage={Boolean(prettifyJSON)}
showLabels={Boolean(showUniqueLabels)}
showTime={showTime}
wrapLogMessage={wrapLogMessage}
/>
</InfiniteScroll>
</div>
<LogListControls eventBus={eventBus} />
</div>
);
};
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',
}),
};

@ -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,
},
},

@ -18,6 +18,7 @@ const contextProps = {
app: CoreApp.Unknown,
dedupStrategy: LogsDedupStrategy.exact,
displayedFields: [],
logs: [],
showControls: false,
showTime: false,
sortOrder: LogsSortOrder.Ascending,

@ -18,6 +18,7 @@ const contextProps = {
app: CoreApp.Unknown,
dedupStrategy: LogsDedupStrategy.exact,
displayedFields: [],
logs: [],
showControls: false,
showTime: false,
sortOrder: LogsSortOrder.Ascending,

@ -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}

@ -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(),

@ -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<Props, 'showControls'> {
export interface LogListContextData extends Omit<Props, 'logs' | 'logsMeta' | 'showControls'> {
downloadLogs: (format: DownloadFormat) => void;
filterLevels: LogLevel[];
setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void;
setDisplayedFields: (displayedFields: string[]) => void;
setFilterLevels: (filterLevels: LogLevel[]) => void;
setLogListState: Dispatch<SetStateAction<LogListState>>;
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<LogListContextData>({
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<LogListState>({
@ -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 (
<LogListContext.Provider
value={{
app,
dedupStrategy: logListState.dedupStrategy,
displayedFields: logListState.displayedFields,
downloadLogs,
filterLevels: logListState.filterLevels,
getRowContextQuery,
logSupportsContext,
@ -268,16 +331,20 @@ export const LogListContextProvider = ({
onUnpinLine,
pinLineButtonTooltipTitle,
pinnedLogs: logListState.pinnedLogs,
prettifyJSON: logListState.prettifyJSON,
setDedupStrategy,
setDisplayedFields,
setFilterLevels,
setLogListState,
setPinnedLogs,
setPrettifyJSON,
setShowTime,
setShowUniqueLabels,
setSortOrder,
setSyntaxHighlighting,
setWrapLogMessage,
showTime: logListState.showTime,
showUniqueLabels: logListState.showUniqueLabels,
sortOrder: logListState.sortOrder,
syntaxHighlighting: logListState.syntaxHighlighting,
wrapLogMessage: logListState.wrapLogMessage,
@ -287,3 +354,16 @@ export const LogListContextProvider = ({
</LogListContext.Provider>
);
};
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
);
}

@ -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(
<LogListContextProvider {...contextProps} showUniqueLabels={false} prettifyJSON={false}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
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(
<LogListContextProvider {...contextProps} showUniqueLabels={false}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Show unique labels'));
rerender(
<LogListContextProvider {...contextProps} showUniqueLabels={false}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
expect(screen.getByLabelText('Hide unique labels'));
});
test('Controls Expand JSON logs', async () => {
const { rerender } = render(
<LogListContextProvider {...contextProps} prettifyJSON={false}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Expand JSON logs'));
rerender(
<LogListContextProvider {...contextProps} showUniqueLabels={false}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
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(
<LogListContextProvider {...contextProps}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
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(
<LogListContextProvider {...contextProps} logs={logs} filterLevels={[LogLevel.error]}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Download logs'));
await userEvent.click(await screen.findByText('txt'));
expect(downloadLogs).toHaveBeenCalledWith('text', filteredLogs, undefined);
});
});

@ -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(
() => (
<Menu>
<Menu.Item
label={t('logs.logs-controls.download-logs.txt', 'txt')}
onClick={() => downloadLogs(DownloadFormat.Text)}
/>
<Menu.Item
label={t('logs.logs-controls.download-logs.json', 'json')}
onClick={() => downloadLogs(DownloadFormat.Json)}
/>
<Menu.Item
label={t('logs.logs-controls.download-logs.csv', 'csv')}
onClick={() => downloadLogs(DownloadFormat.CSV)}
/>
</Menu>
),
[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 && (
<IconButton
name="tag-alt"
aria-pressed={showUniqueLabels}
className={showUniqueLabels ? styles.controlButtonActive : styles.controlButton}
onClick={onShowUniqueLabelsClick}
tooltip={
showUniqueLabels
? t('logs.logs-controls.hide-unique-labels', 'Hide unique labels')
: t('logs.logs-controls.show-unique-labels', 'Show unique labels')
}
size="lg"
/>
)}
<IconButton
name="wrap-text"
className={wrapLogMessage ? styles.controlButtonActive : styles.controlButton}
@ -224,18 +279,48 @@ export const LogListControls = ({ eventBus }: Props) => {
}
size="lg"
/>
<IconButton
name="brackets-curly"
className={syntaxHighlighting ? styles.controlButtonActive : styles.controlButton}
aria-pressed={syntaxHighlighting}
onClick={onSyntaxHightlightingClick}
tooltip={
syntaxHighlighting
? t('logs.logs-controls.disable-highlighting', 'Disable highlighting')
: t('logs.logs-controls.enable-highlighting', 'Enable highlighting')
}
size="lg"
/>
{prettifyJSON !== undefined && (
<IconButton
name="brackets-curly"
aria-pressed={prettifyJSON}
className={prettifyJSON ? styles.controlButtonActive : styles.controlButton}
onClick={onSetPrettifyJSONClick}
tooltip={
prettifyJSON
? t('logs.logs-controls.disable-prettify-json', 'Collapse JSON logs')
: t('logs.logs-controls.prettify-json', 'Expand JSON logs')
}
size="lg"
/>
)}
{syntaxHighlighting !== undefined && (
<IconButton
name="brackets-curly"
className={syntaxHighlighting ? styles.controlButtonActive : styles.controlButton}
aria-pressed={syntaxHighlighting}
onClick={onSyntaxHightlightingClick}
tooltip={
syntaxHighlighting
? t('logs.logs-controls.disable-highlighting', 'Disable highlighting')
: t('logs.logs-controls.enable-highlighting', 'Enable highlighting')
}
size="lg"
/>
)}
{!config.exploreHideLogsDownload && (
<>
<div className={styles.divider} />
<Dropdown overlay={downloadMenu} placement="auto-end">
<IconButton
name="download-alt"
className={styles.controlButton}
aria-pressed={wrapLogMessage}
tooltip={t('logs.logs-controls.download', 'Download logs')}
size="lg"
/>
</Dropdown>
</>
)}
</>
) : (
<Dropdown overlay={filterLevelsMenu} placement="auto-end">
@ -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,

@ -8,13 +8,16 @@ export const LogListContext = createContext<LogListContextData>({
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(),

@ -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<string, DataFrame>();
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<DataTransformerConfig | CustomTransformOperator> = 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<DataFrame[]>) => {
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],
};
});
})
);
};

@ -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"
},

Loading…
Cancel
Save