Logs: migration of columns in the url, update tests

This commit is contained in:
L2D2Grafana
2026-01-07 07:43:00 -08:00
parent aacaf0ca26
commit 05319502a0
3 changed files with 125 additions and 8 deletions
+120 -2
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,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: 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() ?? {};
@@ -344,7 +347,122 @@ const UnthemedLogs: React.FunctionComponent<Props> = (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, string> | 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<string, string> = {};
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(
@@ -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,
});
@@ -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<string, number>, value: string, idx) => ({
...acc,
[value]: idx,
}),
{}
),
includeByName: Object.values(options.panelState.logs.columns).reduce(
includeByName: options.panelState.logs.displayedFields.reduce(
(acc: Record<string, boolean>, value: string) => ({
...acc,
[value]: true,