diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 2b61b5fd590..bda62ec98d1 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -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,10 +46,12 @@ 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'; import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll'; +import { LOG_LINE_BODY_FIELD_NAME, TABLE_LINE_FIELD_NAME } from 'app/features/logs/components/LogDetailsBody'; import { LogRows } from 'app/features/logs/components/LogRows'; import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; import { LogLineContext } from 'app/features/logs/components/panel/LogLineContext'; @@ -74,6 +75,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'; @@ -213,6 +215,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const logsContainerRef = useRef(null); const dispatch = useDispatch(); const previousLoading = usePrevious(loading); + const { location } = useGrafana(); const logsVolumeEventBus = eventBus.newScopedBus('logsvolume', { onlyLocal: false }); const { register, unregister, outlineItems, updateItem } = useContentOutlineContext() ?? {}; @@ -344,7 +347,122 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { ] ); - // No longer needed - displayedFields is read directly from Redux state + // Migration: Convert legacy 'columns' parameter from URL to 'displayedFields' + useEffect(() => { + // Parse URL to check for legacy columns + const urlParams = location.getSearchObject(); + const [urlState] = parseURL(urlParams); + const urlPane = urlState.panes[exploreId]; + + if (!urlPane?.panelsState?.logs) { + return; + } + + // Check for legacy columns in URL panelsState + const logsState = urlPane.panelsState.logs; + const hasColumns = 'columns' in logsState; + if (!hasColumns) { + return; + } + + // Use Object.getOwnPropertyDescriptor to safely access the property + const columnsDescriptor = Object.getOwnPropertyDescriptor(logsState, 'columns'); + const columnsValue = columnsDescriptor?.value; + + // Type guard to validate columns format + let legacyColumns: Record | string[] | undefined = undefined; + if (Array.isArray(columnsValue)) { + legacyColumns = columnsValue; + } else if (typeof columnsValue === 'object' && columnsValue !== null && !Array.isArray(columnsValue)) { + const values = Object.values(columnsValue); + if (values.length > 0 && values.every((v) => typeof v === 'string')) { + // Create a new object to avoid type assertion + const record: Record = {}; + for (const [key, value] of Object.entries(columnsValue)) { + if (typeof value === 'string') { + record[key] = value; + } + } + legacyColumns = record; + } + } + + if (!legacyColumns) { + return; + } + + // Convert columns to array format + let columnsArray: string[] = []; + if (Array.isArray(legacyColumns)) { + columnsArray = legacyColumns; + } else if (typeof legacyColumns === 'object' && legacyColumns !== null) { + // Handle object format like {0: 'Time', 1: 'Line'} + columnsArray = Object.values(legacyColumns); + } + + // Map legacy field names to new field names + const mappedColumns = columnsArray.map((column) => { + // Map 'Line' to LOG_LINE_BODY_FIELD_NAME + if (column === TABLE_LINE_FIELD_NAME) { + return LOG_LINE_BODY_FIELD_NAME; + } + // Other fields like 'Time' stay the same + return column; + }); + + // Merge with defaultDisplayedFields: defaults first, then migrated columns (avoid duplicates) + const mergedFields: string[] = [...defaultDisplayedFields]; + mappedColumns.forEach((column) => { + if (!mergedFields.includes(column)) { + mergedFields.push(column); + } + }); + + // Update displayedFields in state + const state: ExploreItemState | undefined = getState().explore.panes[exploreId]; + if (state?.panelsState?.logs) { + // Create new logs state without columns property + // Explicitly exclude 'columns' by copying only the properties we want + const logsState = state.panelsState.logs; + const newLogsState: ExploreLogsPanelState = { + visualisationType: logsState.visualisationType, + displayedFields: mergedFields, + ...(logsState.labelFieldName && { labelFieldName: logsState.labelFieldName }), + ...(logsState.refId && { refId: logsState.refId }), + ...(logsState.id && { id: logsState.id }), + }; + + dispatch(changePanelState(exploreId, 'logs', newLogsState)); + + // Remove columns from URL by re-serializing all panes + // Use the updated state we just created (without columns) instead of reading from Redux + const allPanes = getState().explore.panes; + const panesObj = Object.entries(allPanes).reduce((acc, [id, paneState]) => { + if (!paneState) { + return acc; + } + // For the current exploreId, use the updated state without columns + let stateToSerialize = paneState; + if (id === exploreId) { + // Create updated pane state with the new logs state (without columns) + stateToSerialize = { + ...paneState, + panelsState: { + ...paneState.panelsState, + logs: newLogsState, + }, + }; + } + return { + ...acc, + [id]: getUrlStateFromPaneState(stateToSerialize), + }; + }, {}); + + // Update URL - columns will be excluded since it's not in the type definition + location.partial({ panes: JSON.stringify(panesObj) }, false); + } + }, [exploreId, displayedFields, defaultDisplayedFields, dispatch, location]); // actions const onLogRowHover = useCallback( diff --git a/public/app/features/explore/Logs/LogsTableWrap.test.tsx b/public/app/features/explore/Logs/LogsTableWrap.test.tsx index f5bd3498b87..cccef55e349 100644 --- a/public/app/features/explore/Logs/LogsTableWrap.test.tsx +++ b/public/app/features/explore/Logs/LogsTableWrap.test.tsx @@ -67,7 +67,7 @@ describe('LogsTableWrap', () => { setup({ panelState: { visualisationType: 'table', - columns: undefined, + displayedFields: undefined, }, updatePanelState: updatePanelState, }); @@ -109,7 +109,7 @@ describe('LogsTableWrap', () => { setup({ panelState: { visualisationType: 'table', - columns: undefined, + displayedFields: undefined, }, updatePanelState: updatePanelState, }); diff --git a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts index ea78a8f81ff..66d5239fc76 100644 --- a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts +++ b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts @@ -24,7 +24,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({ @@ -34,19 +34,18 @@ function getLogsTableTransformations( }, }); } - // Show the columns that the user selected in explore transformations.push({ id: 'organize', options: { - indexByName: Object.values(options.panelState.logs.columns).reduce( + indexByName: options.panelState.logs.displayedFields.reduce( (acc: Record, value: string, idx) => ({ ...acc, [value]: idx, }), {} ), - includeByName: Object.values(options.panelState.logs.columns).reduce( + includeByName: options.panelState.logs.displayedFields.reduce( (acc: Record, value: string) => ({ ...acc, [value]: true,