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
This commit is contained in:
@@ -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
|
||||
|
||||
|
@@ -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>
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user