mirror of
https://github.com/grafana/grafana.git
synced 2025-12-24 05:44:14 +08:00
Compare commits
22 Commits
bugfix/fil
...
l2d2/1462-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a60d4942d4 | ||
|
|
eb8e82f6d0 | ||
|
|
049eaef6f3 | ||
|
|
9dd413ef3d | ||
|
|
73db9dc9fe | ||
|
|
08133bc0f5 | ||
|
|
d7d2f0129a | ||
|
|
ce20dc29e5 | ||
|
|
5c66cc6212 | ||
|
|
33aa6fa947 | ||
|
|
d3dbeab226 | ||
|
|
18f70369e0 | ||
|
|
461d2eb705 | ||
|
|
5eb36b72ab | ||
|
|
a592e2041f | ||
|
|
3e58c89e63 | ||
|
|
f2245723fb | ||
|
|
320043e73a | ||
|
|
94b1077729 | ||
|
|
52c9d06d4c | ||
|
|
9227e8ad9c | ||
|
|
65a7fa38e0 |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
380
public/app/features/explore/Logs/utils/columnMigration.test.ts
Normal file
380
public/app/features/explore/Logs/utils/columnMigration.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
183
public/app/features/explore/Logs/utils/columnMigration.ts
Normal file
183
public/app/features/explore/Logs/utils/columnMigration.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user