Compare commits

...

22 Commits

Author SHA1 Message Date
L2D2Grafana
a60d4942d4 Logs: hide time in the logs panel view 2025-12-12 13:02:24 -08:00
L2D2Grafana
eb8e82f6d0 Logs: remove test comments 2025-12-12 12:39:02 -08:00
L2D2Grafana
049eaef6f3 Logs: update activeField comments 2025-12-12 12:27:05 -08:00
L2D2Grafana
9dd413ef3d Logs: i18n 2025-12-12 12:10:02 -08:00
L2D2Grafana
73db9dc9fe Logs: update to latest main 2025-12-12 12:01:08 -08:00
L2D2Grafana
08133bc0f5 Logs: update tests 2025-12-12 11:34:41 -08:00
L2D2Grafana
d7d2f0129a Logs: only run the url migration if columns are present 2025-12-12 11:34:40 -08:00
L2D2Grafana
ce20dc29e5 Logs: displayFields migration us columns for vistype table 2025-12-12 11:34:40 -08:00
L2D2Grafana
5c66cc6212 Logs: fix logs table reset 2025-12-12 11:34:40 -08:00
L2D2Grafana
33aa6fa947 Logs: disable link to log line when log line is not there 2025-12-12 11:34:39 -08:00
L2D2Grafana
d3dbeab226 Logs: fix detected_level column width 2025-12-12 11:34:39 -08:00
L2D2Grafana
18f70369e0 Logs: remove false 2025-12-12 11:34:38 -08:00
L2D2Grafana
461d2eb705 Logs: use refId 2025-12-12 11:34:38 -08:00
L2D2Grafana
5eb36b72ab Logs: abstract url migration and add unit tests 2025-12-12 11:34:37 -08:00
L2D2Grafana
a592e2041f Logs: fix e2e tests 2025-12-12 11:34:37 -08:00
L2D2Grafana
3e58c89e63 Logs: fix tests 2025-12-12 11:34:37 -08:00
L2D2Grafana
f2245723fb Logs: fix tests 2025-12-12 11:34:36 -08:00
L2D2Grafana
320043e73a Logs: update tests, check for detected_level 2025-12-12 11:34:33 -08:00
L2D2Grafana
94b1077729 Logs: table detected_level width 2025-12-12 11:31:38 -08:00
L2D2Grafana
52c9d06d4c Logs: migration of columns in the url, update tests 2025-12-12 11:31:38 -08:00
L2D2Grafana
9227e8ad9c Logs: update show original line with defaults 2025-12-12 11:31:38 -08:00
L2D2Grafana
65a7fa38e0 Logs: use displayFields for both logs panel adn table 2025-12-12 11:31:34 -08:00
23 changed files with 1055 additions and 141 deletions

View File

@@ -78,7 +78,6 @@ export interface ExploreTracePanelState {
export interface ExploreLogsPanelState {
id?: string;
columns?: Record<number, string>;
visualisationType?: 'table' | 'logs';
labelFieldName?: string;
// Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized

View File

@@ -61,6 +61,16 @@ jest.mock('../state/query', () => ({
},
}));
jest.mock('app/core/context/GrafanaContext', () => ({
...jest.requireActual('app/core/context/GrafanaContext'),
useGrafana: () => ({
location: {
getSearchObject: jest.fn().mockReturnValue({}),
partial: jest.fn(),
},
}),
}));
describe('Logs', () => {
let originalHref = window.location.href;

View File

@@ -30,7 +30,6 @@ import {
serializeStateToUrlParam,
urlUtil,
LogLevel,
shallowCompare,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
@@ -47,6 +46,7 @@ import {
Themeable2,
withTheme2,
} from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import store from 'app/core/store';
import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks';
import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows';
@@ -74,6 +74,7 @@ import {
} from '../ContentOutline/ContentOutlineAnalyticEvents';
import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext';
import { getUrlStateFromPaneState } from '../hooks/useStateSync';
import { parseURL } from '../hooks/useStateSync/parseURL';
import { changePanelState } from '../state/explorePane';
import { changeQueries, runQueries } from '../state/query';
@@ -82,6 +83,7 @@ import { LogsMetaRow } from './LogsMetaRow';
import LogsNavigation from './LogsNavigation';
import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap';
import { LogsVolumePanelList } from './LogsVolumePanelList';
import { migrateLegacyColumns } from './utils/columnMigration';
import { SETTING_KEY_ROOT, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs';
import { getExploreBaseUrl } from './utils/url';
@@ -201,8 +203,9 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
panelState?.logs?.sortOrder ?? store.get(SETTINGS_KEYS.logsSortOrder) ?? LogsSortOrder.Descending
);
const [isFlipping, setIsFlipping] = useState<boolean>(false);
const [displayedFields, setDisplayedFields] = useState<string[]>(panelState?.logs?.displayedFields ?? []);
const [defaultDisplayedFields, setDefaultDisplayedFields] = useState<string[]>([]);
// Use Redux state as single source of truth
const displayedFields = useMemo(() => panelState?.logs?.displayedFields ?? [], [panelState?.logs?.displayedFields]);
const [contextOpen, setContextOpen] = useState<boolean>(false);
const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined);
const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState<PopoverContent>(PINNED_LOGS_MESSAGE);
@@ -212,6 +215,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const dispatch = useDispatch();
const previousLoading = usePrevious(loading);
const { location } = useGrafana();
const logsVolumeEventBus = eventBus.newScopedBus('logsvolume', { onlyLocal: false });
const { register, unregister, outlineItems, updateItem } = useContentOutlineContext() ?? {};
@@ -322,7 +326,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
dispatch(
changePanelState(exploreId, 'logs', {
...state.panelsState.logs,
columns: logsPanelState.columns ?? panelState?.logs?.columns,
visualisationType: logsPanelState.visualisationType ?? visualisationType,
labelFieldName: logsPanelState.labelFieldName,
refId: logsPanelState.refId ?? panelState?.logs?.refId,
@@ -336,7 +339,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
[
dispatch,
exploreId,
panelState?.logs?.columns,
panelState?.logs?.displayedFields,
panelState?.logs?.refId,
panelState?.logs?.tableSortBy,
@@ -345,14 +347,38 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
]
);
// Migration: Convert legacy 'columns' parameter from URL to 'displayedFields'
useEffect(() => {
if (!shallowCompare(displayedFields, panelState?.logs?.displayedFields ?? [])) {
updatePanelState({
...panelState?.logs,
displayedFields,
});
// Parse URL to check for legacy columns
const urlParams = location.getSearchObject();
const [urlState] = parseURL(urlParams);
// Find the pane - exploreId might not match the URL pane key directly
const urlPane = urlState.panes[exploreId] ?? Object.values(urlState.panes)[0];
if (!urlPane?.panelsState?.logs) {
return;
}
}, [displayedFields, panelState?.logs, updatePanelState]);
// Get current displayedFields to use as defaults for merge
const currentDisplayedFields = displayedFields;
// Use migration utility to parse and transform legacy columns
const mergedFields = migrateLegacyColumns(urlPane.panelsState.logs, currentDisplayedFields, visualisationType);
if (!mergedFields) {
return;
}
// Update displayedFields in Redux state - URL sync will handle URL update
dispatch(
changePanelState(exploreId, 'logs', {
...panelState?.logs,
columns: undefined, // Remove columns from URL
displayedFields: mergedFields,
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only on mount
// actions
const onLogRowHover = useCallback(
@@ -541,30 +567,48 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const showField = useCallback(
(key: string) => {
const index = displayedFields.indexOf(key);
const currentFields = panelState?.logs?.displayedFields ?? [];
const index = currentFields.indexOf(key);
if (index === -1) {
const updatedDisplayedFields = displayedFields.concat(key);
setDisplayedFields(updatedDisplayedFields);
const updatedDisplayedFields = currentFields.concat(key);
updatePanelState({
displayedFields: updatedDisplayedFields,
});
}
},
[displayedFields]
[panelState?.logs?.displayedFields, updatePanelState]
);
const hideField = useCallback(
(key: string) => {
const index = displayedFields.indexOf(key);
const currentFields = panelState?.logs?.displayedFields ?? [];
const index = currentFields.indexOf(key);
if (index > -1) {
const updatedDisplayedFields = displayedFields.filter((k) => key !== k);
setDisplayedFields(updatedDisplayedFields);
const updatedDisplayedFields = currentFields.filter((k) => key !== k);
updatePanelState({
displayedFields: updatedDisplayedFields,
});
}
},
[displayedFields]
[panelState?.logs?.displayedFields, updatePanelState]
);
const clearDisplayedFields = useCallback(() => {
setDisplayedFields([]);
}, []);
updatePanelState({
displayedFields: defaultDisplayedFields,
});
}, [defaultDisplayedFields, updatePanelState]);
// Wrapper function for setDisplayedFields prop - updates Redux directly
const setDisplayedFields = useCallback(
(fields: string[]) => {
updatePanelState({
displayedFields: fields,
});
},
[updatePanelState]
);
const onCloseCallbackRef = useRef<() => void>(() => {});
@@ -1003,6 +1047,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
updatePanelState={updatePanelState}
datasourceType={props.datasourceType}
displayedFields={displayedFields}
defaultDisplayedFields={defaultDisplayedFields}
exploreId={props.exploreId}
absoluteRange={props.absoluteRange}
logRows={props.logRows}
@@ -1042,6 +1087,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
displayedFields={displayedFields}
defaultDisplayedFields={defaultDisplayedFields}
onClickShowField={showField}
onClickHideField={hideField}
app={CoreApp.Explore}

View File

@@ -48,7 +48,7 @@ describe('LogsMetaRow', () => {
});
it('renders the show original line button', () => {
setup({ displayedFields: ['test'] });
setup({ displayedFields: ['test'], defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'] });
expect(
screen.getByRole('button', {
name: 'Show original line',
@@ -66,13 +66,20 @@ describe('LogsMetaRow', () => {
});
it('renders the displayed fields', async () => {
setup({ displayedFields: ['testField1234'] });
setup({
displayedFields: ['testField1234'],
defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'],
});
expect(await screen.findByText('testField1234')).toBeInTheDocument();
});
it('renders a button to clear displayedfields', () => {
const clearSpy = jest.fn();
setup({ displayedFields: ['testField1234'], clearDisplayedFields: clearSpy });
setup({
displayedFields: ['testField1234'],
defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'],
clearDisplayedFields: clearSpy,
});
fireEvent(
screen.getByRole('button', {
name: 'Show original line',

View File

@@ -1,16 +1,7 @@
import { css } from '@emotion/css';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import {
LogsDedupStrategy,
LogsMetaItem,
LogsMetaKind,
LogRowModel,
CoreApp,
Labels,
store,
shallowCompare,
} from '@grafana/data';
import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels, store } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
@@ -58,6 +49,14 @@ export const LogsMetaRow = memo(
}: Props) => {
const style = useStyles2(getStyles);
// Filter out default fields from displayedFields to show only user-added fields
const nonDefaultFields = useMemo(() => {
if (!displayedFields?.length || !defaultDisplayedFields?.length) {
return [];
}
return displayedFields.filter((field) => !defaultDisplayedFields.includes(field));
}, [displayedFields, defaultDisplayedFields]);
const logsMetaItem: Array<LogsMetaItem | MetaItemProps> = [...meta];
// Add deduplication info
@@ -69,16 +68,12 @@ export const LogsMetaRow = memo(
});
}
// Add detected fields info
if (
visualisationType === 'logs' &&
displayedFields?.length > 0 &&
shallowCompare(displayedFields, defaultDisplayedFields) === false
) {
// Add detected fields info - only show when user has added fields beyond defaults
if (visualisationType === 'logs' && nonDefaultFields.length > 0) {
logsMetaItem.push(
{
label: t('explore.logs-meta-row.label.showing-only-selected-fields', 'Showing only selected fields'),
value: <LogLabelsList labels={displayedFields} />,
value: <LogLabelsList labels={nonDefaultFields} />,
},
{
label: '',

View File

@@ -33,6 +33,8 @@ import {
useStyles2,
} from '@grafana/ui';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal';
import { TABLE_DETECTED_LEVEL_FIELD_NAME } from 'app/features/logs/components/LogDetailsBody';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from 'app/features/logs/components/otel/formats';
import { LogsFrame } from 'app/features/logs/logsFrame';
import { getFieldLinksForExplore } from '../utils/links';
@@ -397,9 +399,10 @@ export function getLogsExtractFields(dataFrame: DataFrame) {
function buildLabelFilters(columnsWithMeta: Record<string, FieldNameMeta>) {
// Create object of label filters to include columns selected by the user
// Exclude OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME from table view
let labelFilters: Record<string, number> = {};
Object.keys(columnsWithMeta)
.filter((key) => columnsWithMeta[key].active)
.filter((key) => columnsWithMeta[key].active && key !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)
.forEach((key) => {
const index = columnsWithMeta[key].index;
// Index should always be defined for any active column
@@ -434,6 +437,11 @@ function getInitialFieldWidth(field: Field): number | undefined {
if (field.type === FieldType.time) {
return 230;
}
// Set constrained width for detected_level
if (field.name === TABLE_DETECTED_LEVEL_FIELD_NAME) {
return 190;
}
// All other fields (including body field) will auto-expand
return undefined;
}

View File

@@ -48,6 +48,9 @@ export function LogsTableActionButtons(props: Props) {
const lineValue = getLineValue();
// Check if line value is available
const isLineValueAvailable = lineValue !== undefined && lineValue !== null && lineValue !== '';
const styles = getStyles(theme);
// Generate link to the log line
@@ -99,23 +102,34 @@ export function LogsTableActionButtons(props: Props) {
}, [absoluteRange, displayedFields, exploreId, logId, logRows, rowIndex, panelState]);
const handleViewClick = () => {
setIsInspecting(true);
if (isLineValueAvailable) {
setIsInspecting(true);
}
};
const viewTooltip = isLineValueAvailable
? t('explore.logs-table.action-buttons.view-log-line', 'View log line')
: t('explore.logs-table.action-buttons.log-line-not-available', 'Log line not available');
const copyLinkTooltip = isLineValueAvailable
? t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')
: t('explore.logs-table.action-buttons.log-line-not-available', 'Log line not available');
return (
<>
<div className={styles.iconWrapper}>
<div className={styles.inspect}>
<IconButton
className={styles.inspectButton}
tooltip={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
tooltip={viewTooltip}
variant="secondary"
aria-label={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
aria-label={viewTooltip}
tooltipPlacement="top"
size="md"
name="eye"
onClick={handleViewClick}
tabIndex={0}
disabled={!isLineValueAvailable}
/>
</div>
<div className={styles.inspect}>
@@ -125,11 +139,12 @@ export function LogsTableActionButtons(props: Props) {
variant="secondary"
fill="text"
size="md"
tooltip={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
tooltip={copyLinkTooltip}
tooltipPlacement="top"
tabIndex={0}
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
aria-label={copyLinkTooltip}
getText={getText}
disabled={!isLineValueAvailable}
/>
</div>
</div>

View File

@@ -67,7 +67,7 @@ describe('LogsTableWrap', () => {
setup({
panelState: {
visualisationType: 'table',
columns: undefined,
displayedFields: undefined,
},
updatePanelState: updatePanelState,
});
@@ -84,7 +84,7 @@ describe('LogsTableWrap', () => {
await waitFor(() => {
expect(updatePanelState).toBeCalledWith({
visualisationType: 'table',
columns: { 0: 'app', 1: 'Line', 2: 'Time' },
displayedFields: ['app', '___LOG_LINE_BODY___', 'Time'],
labelFieldName: 'labels',
});
});
@@ -97,7 +97,7 @@ describe('LogsTableWrap', () => {
await waitFor(() => {
expect(updatePanelState).toBeCalledWith({
visualisationType: 'table',
columns: { 0: 'Line', 1: 'Time' },
displayedFields: ['___LOG_LINE_BODY___', 'Time'],
labelFieldName: 'labels',
});
});
@@ -109,7 +109,7 @@ describe('LogsTableWrap', () => {
setup({
panelState: {
visualisationType: 'table',
columns: undefined,
displayedFields: undefined,
},
updatePanelState: updatePanelState,
});

View File

@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { Resizable, ResizeCallback } from 're-resizable';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
DataFrame,
@@ -14,15 +14,23 @@ import {
store,
TimeRange,
AbsoluteTimeRange,
shallowCompare,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { getDragStyles, InlineField, Select, useStyles2 } from '@grafana/ui';
import {
TABLE_TIME_FIELD_NAME,
TABLE_LINE_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
} from 'app/features/logs/components/LogDetailsBody';
import {
getFieldSelectorWidth,
LogsTableFieldSelector,
MIN_WIDTH,
} from 'app/features/logs/components/fieldSelector/FieldSelector';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from 'app/features/logs/components/otel/formats';
import { reportInteractionOnce } from 'app/features/logs/components/panel/analytics';
import { parseLogsFrame } from '../../logs/logsFrame';
@@ -44,6 +52,7 @@ interface Props {
datasourceType?: string;
exploreId?: string;
displayedFields?: string[];
defaultDisplayedFields?: string[];
absoluteRange?: AbsoluteTimeRange;
logRows?: LogRowModel[];
}
@@ -69,10 +78,15 @@ type FieldName = string;
export type FieldNameMetaStore = Record<FieldName, FieldNameMeta>;
export function LogsTableWrap(props: Props) {
const { logsFrames, updatePanelState, panelState } = props;
const propsColumns = panelState?.columns;
const { logsFrames, updatePanelState, panelState, defaultDisplayedFields } = props;
const propsColumns = panelState?.displayedFields;
// Save the normalized cardinality of each label
const [columnsWithMeta, setColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined);
// Use ref to access columnsWithMeta in useEffect without causing infinite loops
const columnsWithMetaRef = useRef(columnsWithMeta);
useEffect(() => {
columnsWithMetaRef.current = columnsWithMeta;
}, [columnsWithMeta]);
const dragStyles = useStyles2(getDragStyles);
// Filtered copy of columnsWithMeta that only includes matching results
@@ -86,34 +100,41 @@ export function LogsTableWrap(props: Props) {
logsFrames.find((f) => f.refId === panelStateRefId) ?? logsFrames[0]
);
const logsFrame = useMemo(() => parseLogsFrame(currentDataFrame), [currentDataFrame]);
const getColumnsFromProps = useCallback(
(fieldNames: FieldNameMetaStore) => {
const previouslySelected = props.panelState?.columns;
const previouslySelected = props.panelState?.displayedFields;
if (previouslySelected) {
Object.values(previouslySelected).forEach((key, index) => {
if (fieldNames[key]) {
fieldNames[key].active = true;
fieldNames[key].index = index;
// Map LOG_LINE_BODY_FIELD_NAME to actual body field name
const mappedKey =
key === LOG_LINE_BODY_FIELD_NAME ? (logsFrame?.bodyField?.name ?? TABLE_LINE_FIELD_NAME) : key;
if (fieldNames[mappedKey]) {
fieldNames[mappedKey].active = true;
fieldNames[mappedKey].index = index;
}
});
}
return fieldNames;
},
[props.panelState?.columns]
[props.panelState?.displayedFields, logsFrame?.bodyField?.name]
);
const logsFrame = useMemo(() => parseLogsFrame(currentDataFrame), [currentDataFrame]);
useEffect(() => {
if (logsFrame?.timeField.name && logsFrame?.bodyField.name && !propsColumns) {
const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' };
// Use defaultDisplayedFields if available, otherwise fall back to basic defaults
const columns = defaultDisplayedFields?.length
? defaultDisplayedFields
: [logsFrame?.timeField.name, logsFrame?.bodyField.name];
updatePanelState({
columns: Object.values(defaultColumns),
displayedFields: columns,
visualisationType: 'table',
labelFieldName: logsFrame?.getLabelFieldName() ?? undefined,
});
}
}, [logsFrame, propsColumns, updatePanelState]);
}, [logsFrame, propsColumns, updatePanelState, defaultDisplayedFields]);
/**
* When logs frame updates (e.g. query|range changes), we need to set the selected frame to state
@@ -187,6 +208,7 @@ export function LogsTableWrap(props: Props) {
// If we have labels and log lines
if (labels?.length && numberOfLogLines) {
const displayedFields = props.panelState?.displayedFields ?? [];
// Iterate through all of Labels
labels.forEach((labels: Labels) => {
const labelsArray = Object.keys(labels);
@@ -196,11 +218,19 @@ export function LogsTableWrap(props: Props) {
if (labelCardinality.has(label)) {
const value = labelCardinality.get(label);
if (value) {
if (value?.active) {
// Check displayedFields first, then fall back to current value
const isActiveInDisplayedFields = displayedFields.includes(label);
const currentMeta = columnsWithMetaRef.current?.[label];
const shouldBeActive = isActiveInDisplayedFields || currentMeta?.active || value.active;
const index = isActiveInDisplayedFields
? displayedFields.indexOf(label)
: (currentMeta?.index ?? value.index);
if (shouldBeActive && index !== undefined) {
labelCardinality.set(label, {
percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1,
active: true,
index: value.index,
index: index,
});
} else {
labelCardinality.set(label, {
@@ -212,7 +242,25 @@ export function LogsTableWrap(props: Props) {
}
// Otherwise add it
} else {
labelCardinality.set(label, { percentOfLinesWithLabel: 1, active: false, index: undefined });
// Check if this label is in displayedFields
const isActiveInDisplayedFields = displayedFields.includes(label);
const currentMeta = columnsWithMetaRef.current?.[label];
const shouldBeActive = isActiveInDisplayedFields || currentMeta?.active || false;
const index = isActiveInDisplayedFields ? displayedFields.indexOf(label) : currentMeta?.index;
if (shouldBeActive && index !== undefined) {
labelCardinality.set(label, {
percentOfLinesWithLabel: 1,
active: true,
index: index,
});
} else {
labelCardinality.set(label, {
percentOfLinesWithLabel: 1,
active: false,
index: undefined,
});
}
}
});
});
@@ -230,9 +278,14 @@ export function LogsTableWrap(props: Props) {
}
// Normalize the other fields
const displayedFields = props.panelState?.displayedFields ?? [];
otherFields.forEach((field) => {
const isActive = pendingLabelState[field.name]?.active;
const index = pendingLabelState[field.name]?.index;
// Check displayedFields first, then fall back to current columnsWithMeta
const isActiveInDisplayedFields = displayedFields.includes(field.name);
const currentMeta = columnsWithMetaRef.current?.[field.name];
const isActive = isActiveInDisplayedFields || currentMeta?.active || false;
const index = isActiveInDisplayedFields ? displayedFields.indexOf(field.name) : currentMeta?.index;
if (isActive && index !== undefined) {
pendingLabelState[field.name] = {
percentOfLinesWithLabel: normalize(
@@ -274,10 +327,13 @@ export function LogsTableWrap(props: Props) {
pendingLabelState[logsFrame.timeField.name].type = 'TIME_FIELD';
}
setColumnsWithMeta(pendingLabelState);
// Only update if the state actually changed to prevent infinite loops
if (!columnsWithMetaRef.current || !shallowCompare(columnsWithMetaRef.current, pendingLabelState)) {
setColumnsWithMeta(pendingLabelState);
}
// The panel state is updated when the user interacts with the multi-select sidebar
}, [currentDataFrame, getColumnsFromProps]);
}, [currentDataFrame, getColumnsFromProps, props.panelState?.displayedFields]);
const [sidebarWidth, setSidebarWidth] = useState(getFieldSelectorWidth(SETTING_KEY_ROOT));
const tableWidth = props.width - sidebarWidth;
@@ -323,17 +379,33 @@ export function LogsTableWrap(props: Props) {
const clearSelection = () => {
const pendingLabelState = { ...columnsWithMeta };
Object.keys(pendingLabelState).forEach((key) => {
const isDefaultField = !!pendingLabelState[key].type;
// after reset the only active fields are the special time and body fields
pendingLabelState[key].active = isDefaultField ? true : false;
// reset the index
if (pendingLabelState[key].type === 'TIME_FIELD') {
pendingLabelState[key].index = 0;
const field = pendingLabelState[key];
const isTimeField = field.type === 'TIME_FIELD' || key === TABLE_TIME_FIELD_NAME;
const isBodyField = field.type === 'BODY_FIELD' || key === TABLE_LINE_FIELD_NAME;
const isDetectedLevel = key === TABLE_DETECTED_LEVEL_FIELD_NAME;
// After reset, only active fields are Time, detected_level, and Line
if (isTimeField || isBodyField || isDetectedLevel) {
pendingLabelState[key].active = true;
// Set indices: Time at 0, detected_level at 1, Line at 2
if (isTimeField) {
pendingLabelState[key].index = 0;
} else if (isDetectedLevel) {
pendingLabelState[key].index = 1;
} else if (isBodyField) {
pendingLabelState[key].index = 2;
}
} else {
pendingLabelState[key].index = pendingLabelState[key].type === 'BODY_FIELD' ? 1 : undefined;
pendingLabelState[key].active = false;
pendingLabelState[key].index = undefined;
}
});
setColumnsWithMeta(pendingLabelState);
// Reset displayedFields to defaults
updatePanelState({
displayedFields: defaultDisplayedFields?.length ? defaultDisplayedFields : [],
});
};
const reorderColumn = (newColumns: string[]) => {
@@ -364,17 +436,29 @@ export function LogsTableWrap(props: Props) {
return 0;
});
const newColumns: Record<number, string> = Object.assign(
{},
// Get the keys of the object as an array
newColumnsArray
);
// Map body field name to LOG_LINE_BODY_FIELD_NAME
const bodyFieldName = logsFrame?.bodyField?.name ?? TABLE_LINE_FIELD_NAME;
const bodyFieldIndex = newColumnsArray.indexOf(bodyFieldName);
if (bodyFieldIndex !== -1) {
// Replace body field name with LOG_LINE_BODY_FIELD_NAME
newColumnsArray[bodyFieldIndex] = LOG_LINE_BODY_FIELD_NAME;
}
const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' };
// Preserve ___OTEL_LOG_ATTRIBUTES___ from displayedFields if it exists
const currentDisplayedFields = props.panelState?.displayedFields ?? [];
const otelAttributesIndex = currentDisplayedFields.indexOf(OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME);
if (otelAttributesIndex !== -1 && !newColumnsArray.includes(OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)) {
// Insert at original position if it was in displayedFields
newColumnsArray.splice(otelAttributesIndex, 0, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME);
}
const defaultColumns: string[] = [logsFrame?.timeField.name, logsFrame?.bodyField.name].filter(
(name): name is string => name !== undefined
);
const newPanelState: ExploreLogsPanelState = {
...props.panelState,
// URL format requires our array of values be an object, so we convert it using object.assign
columns: Object.keys(newColumns).length ? newColumns : defaultColumns,
displayedFields: newColumnsArray.length ? newColumnsArray : defaultColumns,
refId: currentDataFrame.refId,
visualisationType: 'table',
labelFieldName: logsFrame?.getLabelFieldName() ?? undefined,
@@ -447,7 +531,6 @@ export function LogsTableWrap(props: Props) {
setFilteredColumnsWithMeta(pendingFilteredLabelState);
}
updateExploreState(pendingLabelState);
};

View File

@@ -0,0 +1,380 @@
import { LOG_LINE_BODY_FIELD_NAME, TABLE_LINE_FIELD_NAME } from 'app/features/logs/components/LogDetailsBody';
import {
parseLegacyColumns,
mapLegacyFieldNames,
mergeWithDefaults,
hasLegacyColumns,
extractColumnsValue,
extractDisplayedFields,
migrateLegacyColumns,
} from './columnMigration';
describe('columnMigration', () => {
describe('parseLegacyColumns', () => {
it('should return null for null input', () => {
expect(parseLegacyColumns(null)).toBeNull();
});
it('should return null for undefined input', () => {
expect(parseLegacyColumns(undefined)).toBeNull();
});
it('should return null for empty array', () => {
expect(parseLegacyColumns([])).toBeNull();
});
it('should return null for empty object', () => {
expect(parseLegacyColumns({})).toBeNull();
});
it('should parse array format correctly', () => {
const input = ['Time', 'Line', 'level'];
expect(parseLegacyColumns(input)).toEqual(['Time', 'Line', 'level']);
});
it('should parse object format correctly', () => {
const input = { 0: 'Time', 1: 'Line', 2: 'level' };
expect(parseLegacyColumns(input)).toEqual(['Time', 'Line', 'level']);
});
it('should return null for array with non-string elements', () => {
const input = ['Time', 123, 'level'];
expect(parseLegacyColumns(input)).toBeNull();
});
it('should return null for object with non-string values', () => {
const input = { 0: 'Time', 1: 123, 2: 'level' };
expect(parseLegacyColumns(input)).toBeNull();
});
it('should return null for primitive types', () => {
expect(parseLegacyColumns('string')).toBeNull();
expect(parseLegacyColumns(123)).toBeNull();
expect(parseLegacyColumns(true)).toBeNull();
});
it('should handle single element array', () => {
expect(parseLegacyColumns(['Time'])).toEqual(['Time']);
});
it('should handle single property object', () => {
expect(parseLegacyColumns({ 0: 'Time' })).toEqual(['Time']);
});
it('should parse real URL format with string numeric keys', () => {
// Real format from URL: columns%22:%7B%220%22:%22cluster%22,%221%22:%22Line%22,%222%22:%22Time%22%7D
// Decoded: {"0":"cluster","1":"Line","2":"Time"}
const input = { '0': 'cluster', '1': 'Line', '2': 'Time' };
expect(parseLegacyColumns(input)).toEqual(['cluster', 'Line', 'Time']);
});
});
describe('mapLegacyFieldNames', () => {
it('should map Line to LOG_LINE_BODY_FIELD_NAME', () => {
const input = [TABLE_LINE_FIELD_NAME];
expect(mapLegacyFieldNames(input)).toEqual([LOG_LINE_BODY_FIELD_NAME]);
});
it('should preserve other field names', () => {
const input = ['Time', 'level', 'host'];
expect(mapLegacyFieldNames(input)).toEqual(['Time', 'level', 'host']);
});
it('should map Line while preserving other fields', () => {
const input = ['Time', TABLE_LINE_FIELD_NAME, 'level'];
expect(mapLegacyFieldNames(input)).toEqual(['Time', LOG_LINE_BODY_FIELD_NAME, 'level']);
});
it('should handle empty array', () => {
expect(mapLegacyFieldNames([])).toEqual([]);
});
it('should handle multiple Line fields', () => {
const input = [TABLE_LINE_FIELD_NAME, TABLE_LINE_FIELD_NAME];
expect(mapLegacyFieldNames(input)).toEqual([LOG_LINE_BODY_FIELD_NAME, LOG_LINE_BODY_FIELD_NAME]);
});
});
describe('mergeWithDefaults', () => {
it('should return defaults when migrated columns is empty', () => {
const defaults = ['Time', 'body'];
expect(mergeWithDefaults([], defaults)).toEqual(['Time', 'body']);
});
it('should return migrated columns when defaults is empty', () => {
const migrated = ['Time', 'level'];
expect(mergeWithDefaults(migrated, [])).toEqual(['Time', 'level']);
});
it('should place defaults first', () => {
const migrated = ['level', 'host'];
const defaults = ['Time', 'body'];
const result = mergeWithDefaults(migrated, defaults);
expect(result).toEqual(['Time', 'body', 'level', 'host']);
});
it('should not duplicate fields', () => {
const migrated = ['Time', 'level'];
const defaults = ['Time', 'body'];
const result = mergeWithDefaults(migrated, defaults);
expect(result).toEqual(['Time', 'body', 'level']);
});
it('should handle all duplicates', () => {
const migrated = ['Time', 'body'];
const defaults = ['Time', 'body'];
const result = mergeWithDefaults(migrated, defaults);
expect(result).toEqual(['Time', 'body']);
});
it('should preserve order of defaults', () => {
const migrated = ['host'];
const defaults = ['body', 'Time', 'level'];
const result = mergeWithDefaults(migrated, defaults);
expect(result[0]).toBe('body');
expect(result[1]).toBe('Time');
expect(result[2]).toBe('level');
expect(result[3]).toBe('host');
});
});
describe('hasLegacyColumns', () => {
it('should return false for null', () => {
expect(hasLegacyColumns(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(hasLegacyColumns(undefined)).toBe(false);
});
it('should return false for non-object', () => {
expect(hasLegacyColumns('string')).toBe(false);
expect(hasLegacyColumns(123)).toBe(false);
});
it('should return false for object without columns property', () => {
expect(hasLegacyColumns({ displayedFields: ['Time'] })).toBe(false);
});
it('should return true for object with columns property', () => {
expect(hasLegacyColumns({ columns: ['Time', 'Line'] })).toBe(true);
});
it('should return true even if columns is null', () => {
expect(hasLegacyColumns({ columns: null })).toBe(true);
});
it('should return true even if columns is empty', () => {
expect(hasLegacyColumns({ columns: [] })).toBe(true);
});
});
describe('extractColumnsValue', () => {
it('should extract columns array', () => {
const state = { columns: ['Time', 'Line'] };
expect(extractColumnsValue(state)).toEqual(['Time', 'Line']);
});
it('should extract columns object', () => {
const state = { columns: { 0: 'Time', 1: 'Line' } };
expect(extractColumnsValue(state)).toEqual({ 0: 'Time', 1: 'Line' });
});
it('should return undefined when columns not present', () => {
const state = { displayedFields: ['Time'] };
expect(extractColumnsValue(state)).toBeUndefined();
});
});
describe('extractDisplayedFields', () => {
it('should extract displayedFields array', () => {
const state = { displayedFields: ['Time', 'level', 'host'] };
expect(extractDisplayedFields(state)).toEqual(['Time', 'level', 'host']);
});
it('should return undefined when displayedFields not present', () => {
const state = { columns: ['Time'] };
expect(extractDisplayedFields(state)).toBeUndefined();
});
it('should extract empty displayedFields array', () => {
const state = { displayedFields: [] };
expect(extractDisplayedFields(state)).toEqual([]);
});
it('should handle state with both columns and displayedFields', () => {
const state = {
columns: { '0': 'cluster', '1': 'Line' },
displayedFields: ['service_name', 'component'],
};
expect(extractDisplayedFields(state)).toEqual(['service_name', 'component']);
});
});
describe('migrateLegacyColumns', () => {
const defaultDisplayedFields = ['Time', LOG_LINE_BODY_FIELD_NAME];
describe('general behavior', () => {
it('should return null when logsState is null', () => {
expect(migrateLegacyColumns(null, defaultDisplayedFields, 'table')).toBeNull();
});
it('should return null when logsState is undefined', () => {
expect(migrateLegacyColumns(undefined, defaultDisplayedFields, 'table')).toBeNull();
});
it('should return null when no columns property exists', () => {
const logsState = { displayedFields: ['Time'] };
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'table')).toBeNull();
});
it('should return null when visualisationType is not provided', () => {
const logsState = { columns: ['Time', 'level'] };
expect(migrateLegacyColumns(logsState, defaultDisplayedFields)).toBeNull();
});
it('should return null when visualisationType is unknown', () => {
const logsState = { columns: ['Time', 'level'] };
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'unknown')).toBeNull();
});
});
describe('visualisationType: table', () => {
it('should return null when columns is empty array', () => {
const logsState = { columns: [] };
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'table')).toBeNull();
});
it('should return null when columns is invalid', () => {
const logsState = { columns: 'invalid' };
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'table')).toBeNull();
});
it('should migrate array format columns', () => {
const logsState = { columns: ['Time', TABLE_LINE_FIELD_NAME, 'level'] };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual(['Time', LOG_LINE_BODY_FIELD_NAME, 'level']);
});
it('should migrate object format columns', () => {
const logsState = { columns: { 0: 'Time', 1: TABLE_LINE_FIELD_NAME, 2: 'level' } };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual(['Time', LOG_LINE_BODY_FIELD_NAME, 'level']);
});
it('should return only mapped columns without merging with defaults', () => {
const logsState = { columns: ['level', 'host'] };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
// Table visualization returns only the columns, not merged with defaults
expect(result).toEqual(['level', 'host']);
});
it('should map Line to body field name', () => {
const logsState = { columns: [TABLE_LINE_FIELD_NAME] };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual([LOG_LINE_BODY_FIELD_NAME]);
});
it('should map timestamp to Time', () => {
const logsState = { columns: ['timestamp', 'level'] };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual(['Time', 'level']);
});
it('should map body to LOG_LINE_BODY_FIELD_NAME', () => {
const logsState = { columns: ['body', 'level'] };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual([LOG_LINE_BODY_FIELD_NAME, 'level']);
});
it('should ignore displayedFields and only use columns for table', () => {
const logsState = {
columns: ['level'],
displayedFields: ['existing', 'fields'],
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
// Should only return mapped columns, ignoring displayedFields
expect(result).toEqual(['level']);
});
it('should handle real URL format with full logsState structure', () => {
const logsState = {
columns: { '0': 'cluster', '1': 'Line', '2': 'Time' },
visualisationType: 'table',
labelFieldName: 'labels',
refId: 'A',
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
// Returns mapped columns in order (Line -> LOG_LINE_BODY_FIELD_NAME)
expect(result).toEqual(['cluster', LOG_LINE_BODY_FIELD_NAME, 'Time']);
});
it('should migrate columns from real Grafana Explore URL', () => {
const logsState = {
sortOrder: 'Ascending',
columns: { '0': 'cluster', '1': 'Line', '2': 'Time' },
visualisationType: 'table',
labelFieldName: 'labels',
refId: 'A',
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toContain('cluster');
expect(result).toContain(LOG_LINE_BODY_FIELD_NAME);
expect(result).toContain('Time');
expect(result).not.toContain('Line'); // Line should be mapped
});
it('should map legacy field names correctly', () => {
const logsState = {
columns: { '0': 'timestamp', '1': 'body', '2': 'env', '3': 'namespace' },
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual(['Time', LOG_LINE_BODY_FIELD_NAME, 'env', 'namespace']);
});
});
describe('visualisationType: logs', () => {
it('should return null when no columns property exists (required for migration)', () => {
const logsState = { displayedFields: ['Time', 'level'] };
// logs visualization requires legacy columns to exist for migration to run
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'logs')).toBeNull();
});
it('should return displayedFields directly when columns exist', () => {
const logsState = {
columns: { '0': 'old', '1': 'columns' }, // Legacy columns must exist
displayedFields: ['Time', 'level', 'host'],
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'logs');
expect(result).toEqual(['Time', 'level', 'host']);
});
it('should return null when displayedFields is empty', () => {
const logsState = {
columns: { '0': 'old' },
displayedFields: [],
};
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'logs')).toBeNull();
});
it('should return null when displayedFields is not an array', () => {
const logsState = {
columns: { '0': 'old' },
displayedFields: 'not-an-array',
};
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'logs')).toBeNull();
});
it('should ignore columns and use displayedFields for logs visualization', () => {
const logsState = {
columns: { '0': 'cluster', '1': 'Line', '2': 'Time' },
displayedFields: ['service_name', 'component'],
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'logs');
// Should return displayedFields, ignoring columns
expect(result).toEqual(['service_name', 'component']);
});
});
});
});

View File

@@ -0,0 +1,183 @@
import {
LOG_LINE_BODY_FIELD_NAME,
TABLE_LINE_FIELD_NAME,
TABLE_TIME_FIELD_NAME,
} from 'app/features/logs/components/LogDetailsBody';
/**
* Migration utility for converting legacy 'columns' URL parameter to 'displayedFields'.
*/
/**
* Parses legacy columns value from URL.
* Handles both array format and object format (e.g., {0: 'Time', 1: 'Line'}).
*
* @param columnsValue - The raw columns value from URL state
* @returns Array of column names, or null if invalid/empty
*/
export function parseLegacyColumns(columnsValue: unknown): string[] | null {
if (columnsValue === null || columnsValue === undefined) {
return null;
}
// Handle array format
if (Array.isArray(columnsValue)) {
if (columnsValue.length === 0) {
return null;
}
// Validate all elements are strings
if (columnsValue.every((v) => typeof v === 'string')) {
return columnsValue;
}
return null;
}
// Handle object format (e.g., {0: 'Time', 1: 'Line'})
if (typeof columnsValue === 'object') {
const values = Object.values(columnsValue);
if (values.length === 0) {
return null;
}
// Validate all values are strings and filter to string array
if (values.every((v): v is string => typeof v === 'string')) {
return values;
}
}
return null;
}
/**
* Maps legacy field names to their new equivalents.
* Maps: 'Line' -> LOG_LINE_BODY_FIELD_NAME, 'timestamp' -> 'Time', 'body' -> LOG_LINE_BODY_FIELD_NAME
*
* @param columns - Array of column names
* @returns Array with mapped column names
*/
export function mapLegacyFieldNames(columns: string[]): string[] {
return columns.map((column) => {
// Map 'Line' to LOG_LINE_BODY_FIELD_NAME
if (column === TABLE_LINE_FIELD_NAME) {
return LOG_LINE_BODY_FIELD_NAME;
}
// Map 'timestamp' to TABLE_TIME_FIELD_NAME ('Time')
if (column === 'timestamp') {
return TABLE_TIME_FIELD_NAME;
}
// Map 'body' to LOG_LINE_BODY_FIELD_NAME
if (column === 'body') {
return LOG_LINE_BODY_FIELD_NAME;
}
return column;
});
}
/**
* Merges migrated columns with default displayed fields.
* Default fields come first, then migrated columns (avoiding duplicates).
*
* @param migratedColumns - Columns from the legacy format (already mapped)
* @param defaultFields - Default fields to display
* @returns Merged array with defaults first, no duplicates
*/
export function mergeWithDefaults(migratedColumns: string[], defaultFields: string[]): string[] {
const mergedFields = [...defaultFields];
migratedColumns.forEach((column) => {
if (!mergedFields.includes(column)) {
mergedFields.push(column);
}
});
return mergedFields;
}
/**
* Checks if a logs state object contains legacy columns that need migration.
* Acts as a type guard to narrow the type to an object with columns property.
*
* @param logsState - The logs panel state from URL
* @returns True if legacy columns exist
*/
export function hasLegacyColumns(logsState: unknown): logsState is object & { columns: unknown } {
if (!logsState || typeof logsState !== 'object') {
return false;
}
return 'columns' in logsState;
}
/**
* Extracts the columns value from logs state using safe property access.
*
* @param logsState - The logs panel state from URL
* @returns The columns value, or undefined if not present
*/
export function extractColumnsValue(logsState: object): unknown {
const descriptor = Object.getOwnPropertyDescriptor(logsState, 'columns');
return descriptor?.value;
}
/**
* Extracts the displayedFields value from logs state using safe property access.
*
* @param logsState - The logs panel state from URL
* @returns The displayedFields value, or undefined if not present
*/
export function extractDisplayedFields(logsState: object): unknown {
const descriptor = Object.getOwnPropertyDescriptor(logsState, 'displayedFields');
return descriptor?.value;
}
/**
* Main migration function - orchestrates the full migration process.
* Returns the migrated and merged fields, or null if no migration is needed.
*
* For table visualization: merges defaults with legacy 'columns' from URL
* For logs visualization: merges defaults with 'displayedFields' from URL
*
* @param logsState - The logs panel state from URL
* @param defaultDisplayedFields - Default fields to merge with
* @param visualisationType - The current visualization type ('table' or 'logs')
* @returns Merged displayed fields array, or null if no migration needed
*/
export function migrateLegacyColumns(
logsState: unknown,
defaultDisplayedFields: string[],
visualisationType?: string
): string[] | null {
// Ensure logsState is an object
// Only run this migration if legacy columns are present
if (!logsState || typeof logsState !== 'object' || !hasLegacyColumns(logsState)) {
return null;
}
// For table visualization: only use columns from URL and map the old field names to the new ones
if (visualisationType === 'table') {
const columnsValue = extractColumnsValue(logsState);
const parsedColumns = parseLegacyColumns(columnsValue);
if (!parsedColumns) {
return null;
}
// Map legacy field names to new names
const mappedColumns = mapLegacyFieldNames(parsedColumns);
return mappedColumns;
}
// For logs visualization only use displayedFields from URL
if (visualisationType === 'logs') {
const displayedFieldsValue = extractDisplayedFields(logsState);
// displayedFields should already be an array of strings
if (!Array.isArray(displayedFieldsValue) || displayedFieldsValue.length === 0) {
return null;
}
return displayedFieldsValue;
}
// No visualisationType specified or unknown type - return null
return null;
}

View File

@@ -2,6 +2,8 @@ import { DataFrame, ExplorePanelsState } from '@grafana/data';
import { t } from '@grafana/i18n';
import { DataQuery, DataSourceRef, Panel } from '@grafana/schema';
import { DataTransformerConfig } from '@grafana/schema/dist/esm/raw/dashboard/x/dashboard_types.gen';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_TIME_FIELD_NAME } from 'app/features/logs/components/LogDetailsBody';
import { parseLogsFrame } from 'app/features/logs/logsFrame';
import { ExplorePanelData } from 'app/types/explore';
interface ExploreToDashboardPanelOptions {
@@ -24,7 +26,7 @@ function getLogsTableTransformations(
options: ExploreToDashboardPanelOptions
): DataTransformerConfig[] {
let transformations: DataTransformerConfig[] = [];
if (panelType === 'table' && options.panelState?.logs?.columns) {
if (panelType === 'table' && options.panelState?.logs?.displayedFields) {
// If we have a labels column, we need to extract the fields from it
if (options.panelState.logs?.labelFieldName) {
transformations.push({
@@ -35,18 +37,37 @@ function getLogsTableTransformations(
});
}
// Map constant field names to actual field names from the data frame
// Find the first logs frame to get the actual field names
const logsFrame = options.queryResponse.logsFrames.find((frame) => frame.refId === options.panelState?.logs?.refId);
const parsedLogsFrame = logsFrame ? parseLogsFrame(logsFrame) : null;
// Map displayedFields from constant names to actual field names
const mappedDisplayedFields = options.panelState.logs.displayedFields.map((fieldName) => {
// Map LOG_LINE_BODY_FIELD_NAME to actual body field name
if (fieldName === LOG_LINE_BODY_FIELD_NAME) {
return parsedLogsFrame?.bodyField?.name ?? fieldName;
}
// Map TABLE_TIME_FIELD_NAME to actual time field name
if (fieldName === TABLE_TIME_FIELD_NAME) {
return parsedLogsFrame?.timeField?.name ?? fieldName;
}
// Return as-is for other fields (including extracted labels)
return fieldName;
});
// Show the columns that the user selected in explore
transformations.push({
id: 'organize',
options: {
indexByName: Object.values(options.panelState.logs.columns).reduce(
indexByName: mappedDisplayedFields.reduce(
(acc: Record<string, number>, value: string, idx) => ({
...acc,
[value]: idx,
}),
{}
),
includeByName: Object.values(options.panelState.logs.columns).reduce(
includeByName: mappedDisplayedFields.reduce(
(acc: Record<string, boolean>, value: string) => ({
...acc,
[value]: true,

View File

@@ -43,6 +43,7 @@ export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
width?: number;
logsTableFrames?: DataFrame[];
displayedFields?: string[];
defaultDisplayedFields?: string[];
exploreId?: string;
absoluteRange?: AbsoluteTimeRange;
logRows?: LogRowModel[];

View File

@@ -26,6 +26,7 @@ export const ControlledLogsTable = ({
logsTableFrames,
visualisationType,
displayedFields,
defaultDisplayedFields,
exploreId,
absoluteRange,
logRows,
@@ -63,6 +64,7 @@ export const ControlledLogsTable = ({
updatePanelState={updatePanelState}
datasourceType={datasourceType}
displayedFields={displayedFields}
defaultDisplayedFields={defaultDisplayedFields}
exploreId={exploreId}
absoluteRange={absoluteRange}
logRows={logRows}

View File

@@ -31,6 +31,12 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => {
export const LOG_LINE_BODY_FIELD_NAME = '___LOG_LINE_BODY___';
// Table view field constants
export const TABLE_TIME_FIELD_NAME = 'Time';
export const TABLE_LINE_FIELD_NAME = 'Line';
export const TABLE_DETECTED_LEVEL_FIELD_NAME = 'detected_level';
export const TABLE_LEVEL_FIELD_NAME = 'level';
export const LogDetailsBody = (props: Props) => {
const showField = () => {
const { onClickShowField, row } = props;

View File

@@ -21,22 +21,6 @@ interface Props {
export const ActiveFields = ({ activeFields, clear, fields, reorder, suggestedFields, toggle }: Props) => {
const styles = useStyles2(getLogsFieldsStyles);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) {
return;
}
const newActiveFields = [...activeFields];
const element = activeFields[result.source.index];
newActiveFields.splice(result.source.index, 1);
newActiveFields.splice(result.destination.index, 0, element);
reorder(newActiveFields);
},
[activeFields, reorder]
);
const active = useMemo(
() => [
...activeFields
@@ -48,6 +32,47 @@ export const ActiveFields = ({ activeFields, clear, fields, reorder, suggestedFi
[activeFields, fields, suggestedFields]
);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) {
return;
}
// Get the field names from the active array and use that instead of the index
// This is needed because in the table and logs view some fields are not rendered, so the index is not the same as the index in the activeFields array
const sourceFieldName = active[result.source.index]?.name;
if (!sourceFieldName) {
return;
}
const newActiveFields = [...activeFields];
const sourceIndexInActiveFields = newActiveFields.indexOf(sourceFieldName);
if (sourceIndexInActiveFields === -1) {
return;
}
const [movedField] = newActiveFields.splice(sourceIndexInActiveFields, 1);
const destFieldName = active[result.destination.index]?.name;
if (destFieldName) {
const destIndexInActiveFields = newActiveFields.indexOf(destFieldName);
if (destIndexInActiveFields !== -1) {
const insertIndex =
result.source.index < result.destination.index ? destIndexInActiveFields + 1 : destIndexInActiveFields;
newActiveFields.splice(insertIndex, 0, movedField);
} else {
newActiveFields.push(movedField);
}
} else {
newActiveFields.push(movedField);
}
reorder(newActiveFields);
},
[activeFields, active, reorder]
);
const suggested = useMemo(
() => suggestedFields.filter((suggestedField) => !activeFields.includes(suggestedField.name)),
[activeFields, suggestedFields]

View File

@@ -10,8 +10,8 @@ import { FieldNameMetaStore } from 'app/features/explore/Logs/LogsTableWrap';
import { SETTING_KEY_ROOT } from 'app/features/explore/Logs/utils/logs';
import { parseLogsFrame } from 'app/features/logs/logsFrame';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { getSuggestedFieldsForLogs } from '../otel/formats';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_DETECTED_LEVEL_FIELD_NAME } from '../LogDetailsBody';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, getSuggestedFieldsForLogs } from '../otel/formats';
import { useLogListContext } from '../panel/LogListContext';
import { reportInteractionOnce } from '../panel/analytics';
import { LogListModel } from '../panel/processing';
@@ -103,7 +103,10 @@ export const LogListFieldSelector = ({ containerElement, dataFrames, logs }: Log
);
const suggestedFields = useMemo(() => getSuggestedFields(logs, displayedFields), [displayedFields, logs]);
const fields = useMemo(() => getFieldsWithStats(dataFrames), [dataFrames]);
const fields = useMemo(
() => getFieldsWithStats(dataFrames).filter((field) => field.name !== TABLE_DETECTED_LEVEL_FIELD_NAME),
[dataFrames]
);
if (!onClickShowField || !onClickHideField || !setDisplayedFields) {
console.warn(
@@ -215,7 +218,7 @@ export const LogsTableFieldSelector = ({
const displayedColumns = useMemo(
() =>
Object.keys(columnsWithMeta)
.filter((column) => columnsWithMeta[column].active)
.filter((column) => columnsWithMeta[column].active && column !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)
.sort((a, b) =>
columnsWithMeta[a].index !== undefined && columnsWithMeta[b].index !== undefined
? columnsWithMeta[a].index - columnsWithMeta[b].index
@@ -250,7 +253,10 @@ export const LogsTableFieldSelector = ({
() => getSuggestedFields(logs, displayedColumns, defaultColumns),
[defaultColumns, displayedColumns, logs]
);
const fields = useMemo(() => getFieldsWithStats(dataFrames), [dataFrames]);
const fields = useMemo(
() => getFieldsWithStats(dataFrames).filter((field) => field.name !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME),
[dataFrames]
);
return sidebarWidth > MIN_WIDTH * 2 ? (
<FieldSelector

View File

@@ -17,7 +17,7 @@ import { findHighlightChunksInText, GrafanaTheme2, LogsDedupStrategy, TimeRange
import { t } from '@grafana/i18n';
import { Button, Icon, Tooltip } from '@grafana/ui';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_TIME_FIELD_NAME } from '../LogDetailsBody';
import { LogLabels } from '../LogLabels';
import { LogMessageAnsi } from '../LogMessageAnsi';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
@@ -392,6 +392,10 @@ const DisplayedFields = ({
if (field === LOG_LINE_BODY_FIELD_NAME) {
return <LogLineBody log={log} key={field} styles={styles} />;
}
// Hide Time field - it's already rendered via showTime in the parent Log component
if (field === TABLE_TIME_FIELD_NAME) {
return null;
}
if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME && syntaxHighlighting) {
return (
<span className="field log-syntax-highlight" title={getNormalizedFieldName(field)} key={field}>

View File

@@ -15,7 +15,7 @@ import {
import { config, reportInteraction } from '@grafana/runtime';
import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_TIME_FIELD_NAME, TABLE_DETECTED_LEVEL_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine, createLogRow } from '../mocks/logRow';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats';
@@ -125,12 +125,32 @@ describe('LogList', () => {
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
const logsWithDetectedLevel = [
createLogRow({ uid: '1', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'info' } }),
createLogRow({ uid: '2', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'debug' } }),
];
render(
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
<LogList
{...defaultProps}
logs={logsWithDetectedLevel}
onLogOptionsChange={onLogOptionsChange}
setDisplayedFields={setDisplayedFields}
/>
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
expect(onLogOptionsChange).not.toHaveBeenCalled();
expect(setDisplayedFields).not.toHaveBeenCalled();
// Even when OTel is disabled, we still report table defaults
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
// setDisplayedFields is called with the default fields
expect(setDisplayedFields).toHaveBeenCalledWith([
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
config.featureToggles.otelLogsFormatting = originalState;
});
@@ -140,14 +160,33 @@ describe('LogList', () => {
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
const logsWithDetectedLevel = [
createLogRow({ uid: '1', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'info' } }),
createLogRow({ uid: '2', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'debug' } }),
];
render(
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
<LogList
{...defaultProps}
logs={logsWithDetectedLevel}
onLogOptionsChange={onLogOptionsChange}
setDisplayedFields={setDisplayedFields}
/>
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', []);
// For non-OTel logs, we report table defaults only (no OTel attributes field)
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
// No fields to display, no call
expect(setDisplayedFields).not.toHaveBeenCalled();
// setDisplayedFields is called with the default fields
expect(setDisplayedFields).toHaveBeenCalledWith([
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
config.featureToggles.otelLogsFormatting = originalState;
});
@@ -157,7 +196,12 @@ describe('LogList', () => {
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
const logs = [createLogRow({ uid: '1', labels: { [OTEL_PROBE_FIELD]: '1' } })];
const logs = [
createLogRow({
uid: '1',
labels: { [OTEL_PROBE_FIELD]: '1', [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'info' },
}),
];
render(
<LogList
@@ -168,11 +212,19 @@ describe('LogList', () => {
/>
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
// For OTel logs, we report table defaults + OTel fields
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
]);
expect(setDisplayedFields).toHaveBeenCalledWith([
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
]);
expect(setDisplayedFields).toHaveBeenCalledWith([LOG_LINE_BODY_FIELD_NAME, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]);
config.featureToggles.otelLogsFormatting = originalState;
});

View File

@@ -27,6 +27,12 @@ import { config, getDataSourceSrv } from '@grafana/runtime';
import { PopoverContent } from '@grafana/ui';
import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils';
import {
LOG_LINE_BODY_FIELD_NAME,
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
TABLE_LEVEL_FIELD_NAME,
} from '../LogDetailsBody';
import { getFieldSelectorState } from '../fieldSelector/FieldSelector';
import { getDisplayedFieldsForLogs } from '../otel/formats';
@@ -118,6 +124,27 @@ export const useLogIsPermalinked = (log: LogListModel) => {
return permalinkedLogId && permalinkedLogId === log.uid;
};
/**
* Get default table fields.
* Always returns Time, and detected_level if it exists in the logs (excluding Line).
*/
function getTableDefaultFields(logs: LogRowModel[]): string[] {
const fields: string[] = [TABLE_TIME_FIELD_NAME];
// Check if detected_level exists in any log's labels, fall back to level if not found
const hasDetectedLevel = logs.some((log) => log.labels?.[TABLE_DETECTED_LEVEL_FIELD_NAME] !== undefined);
const hasLevel = !hasDetectedLevel && logs.some((log) => log.labels?.[TABLE_LEVEL_FIELD_NAME] !== undefined);
if (hasDetectedLevel) {
fields.push(TABLE_DETECTED_LEVEL_FIELD_NAME);
} else if (hasLevel) {
// Fall back to level if detected_level is not present
fields.push(TABLE_LEVEL_FIELD_NAME);
}
return fields;
}
export type LogListState = Pick<
LogListContextData,
| 'dedupStrategy'
@@ -263,27 +290,52 @@ export const LogListContextProvider = ({
}, []);
const otelDisplayedFields = useMemo(() => {
if (!config.featureToggles.otelLogsFormatting || !setDisplayedFields || showLogAttributes === false) {
if (!config.featureToggles.otelLogsFormatting) {
return [];
}
if (showLogAttributes === false) {
return [];
}
return getDisplayedFieldsForLogs(logs);
}, [logs, setDisplayedFields, showLogAttributes]);
}, [logs, showLogAttributes]);
// OTel displayed fields
// Get table default fields
const tableDefaultFields = useMemo(() => {
return getTableDefaultFields(logs);
}, [logs]);
// Combine table defaults with OTel defaults in specific order:
// ['Time', 'detected_level', '___LOG_LINE_BODY___', '___OTEL_LOG_ATTRIBUTES___']
const defaultDisplayedFields = useMemo(() => {
const orderedFields: string[] = tableDefaultFields;
// Always add LOG_LINE_BODY before OTel fields
orderedFields.push(LOG_LINE_BODY_FIELD_NAME);
// Add OTel fields, excluding LOG_LINE_BODY_FIELD_NAME if it's already there to avoid duplicates
const otelFieldsWithoutBody = otelDisplayedFields.filter((field) => field !== LOG_LINE_BODY_FIELD_NAME);
orderedFields.push(...otelFieldsWithoutBody);
return orderedFields;
}, [tableDefaultFields, otelDisplayedFields]);
// Pass default displayed fields (table defaults + OTel defaults) to parent
useEffect(() => {
if (config.featureToggles.otelLogsFormatting && showLogAttributes !== false) {
onLogOptionsChange?.('defaultDisplayedFields', otelDisplayedFields);
if (defaultDisplayedFields.length > 0) {
onLogOptionsChange?.('defaultDisplayedFields', defaultDisplayedFields);
}
}, [onLogOptionsChange, otelDisplayedFields, showLogAttributes]);
}, [onLogOptionsChange, defaultDisplayedFields]);
// Set default displayed fields (table defaults + OTel defaults) when displayedFields is empty or missing table defaults
useEffect(() => {
if (displayedFields.length > 0 || !setDisplayedFields) {
if (!setDisplayedFields || defaultDisplayedFields.length === 0) {
return;
}
if (otelDisplayedFields.length) {
setDisplayedFields(otelDisplayedFields);
if (displayedFields.length === 0) {
setDisplayedFields(defaultDisplayedFields);
}
}, [displayedFields.length, otelDisplayedFields, setDisplayedFields]);
}, [displayedFields, defaultDisplayedFields, tableDefaultFields, setDisplayedFields]);
// Sync state
useEffect(() => {

View File

@@ -223,6 +223,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(OLDEST_LOGS_LABEL_REGEX));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('sortOrder', LogsSortOrder.Descending);
@@ -235,6 +237,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(DEDUPE_LABEL_COPY));
await userEvent.click(screen.getByText('Numbers'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
@@ -286,6 +290,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(SHOW_TIMESTAMP_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
@@ -298,6 +304,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(WRAP_LINES_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
@@ -319,6 +327,8 @@ describe('LogListControls', () => {
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText('Wrap disabled'));
await userEvent.click(screen.getByText('Enable line wrapping'));
@@ -354,6 +364,8 @@ describe('LogListControls', () => {
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(TIMESTAMP_LABEL_COPY));
await userEvent.click(screen.getByText('Show millisecond timestamps'));
@@ -381,6 +393,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true);

View File

@@ -156,30 +156,34 @@ export const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => {
return 0;
};
export function sortLogRows(logRows: LogRowModel[], sortOrder: LogsSortOrder) {
return sortOrder === LogsSortOrder.Ascending
? logRows.sort(sortInAscendingOrder)
: logRows.sort(sortInDescendingOrder);
}
export const sortLogsResult = (logsResult: LogsModel | null, sortOrder: LogsSortOrder): LogsModel => {
const rows = logsResult ? sortLogRows(logsResult.rows, sortOrder) : [];
return logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows };
};
export const sortLogRows = (logRows: LogRowModel[], sortOrder: LogsSortOrder) =>
sortOrder === LogsSortOrder.Ascending ? logRows.sort(sortInAscendingOrder) : logRows.sort(sortInDescendingOrder);
// Currently supports only error condition in Loki logs
export const checkLogsError = (logRow: LogRowModel): string | undefined => {
export function checkLogsError(logRow: LogRowModel): string | undefined {
return logRow.labels.__error__;
};
}
export const checkLogsSampled = (logRow: LogRowModel): string | undefined => {
export function checkLogsSampled(logRow: LogRowModel): string | undefined {
if (!logRow.labels.__adaptive_logs_sampled__) {
return undefined;
}
return logRow.labels.__adaptive_logs_sampled__ === 'true'
? 'Logs like this one have been dropped by Adaptive Logs'
: `${logRow.labels.__adaptive_logs_sampled__}% of logs like this one have been dropped by Adaptive Logs`;
};
}
export const escapeUnescapedString = (string: string) =>
string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n'));
export function escapeUnescapedString(string: string) {
return string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n'));
}
export function logRowsToReadableJson(logs: LogRowModel[], pickFields: string[] = []) {
return logs.map((log) => {

View File

@@ -7385,6 +7385,7 @@
"copy-link": "Copy link to log line",
"copy-to-clipboard": "Copy to Clipboard",
"inspect-value": "Inspect value",
"log-line-not-available": "Log line not available",
"view-log-line": "View log line"
}
},