Compare commits
23 Commits
sriram/SQL
...
l2d2/1462-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d3ac161bc | ||
|
|
50635853c8 | ||
|
|
fe985eb5d0 | ||
|
|
c037e39a1a | ||
|
|
2168453109 | ||
|
|
f04118e6c7 | ||
|
|
ddfca17d92 | ||
|
|
fa9cc6bc8c | ||
|
|
a196cc1adb | ||
|
|
70d0e9006b | ||
|
|
e8561c6b0f | ||
|
|
731704fa21 | ||
|
|
6b543d22f9 | ||
|
|
c234f41691 | ||
|
|
86a12469a7 | ||
|
|
e538c2f3c9 | ||
|
|
0a3892a52e | ||
|
|
dc20e8bb2b | ||
|
|
099c75b632 | ||
|
|
4de55500d8 | ||
|
|
05319502a0 | ||
|
|
aacaf0ca26 | ||
|
|
1e841eaba8 |
@@ -78,7 +78,6 @@ export interface ExploreTracePanelState {
|
|||||||
|
|
||||||
export interface ExploreLogsPanelState {
|
export interface ExploreLogsPanelState {
|
||||||
id?: string;
|
id?: string;
|
||||||
columns?: Record<number, string>;
|
|
||||||
visualisationType?: 'table' | 'logs';
|
visualisationType?: 'table' | 'logs';
|
||||||
labelFieldName?: string;
|
labelFieldName?: string;
|
||||||
// Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized
|
// 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', () => {
|
describe('Logs', () => {
|
||||||
let originalHref = window.location.href;
|
let originalHref = window.location.href;
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
serializeStateToUrlParam,
|
serializeStateToUrlParam,
|
||||||
urlUtil,
|
urlUtil,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
shallowCompare,
|
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { config, reportInteraction } from '@grafana/runtime';
|
import { config, reportInteraction } from '@grafana/runtime';
|
||||||
@@ -47,6 +46,7 @@ import {
|
|||||||
Themeable2,
|
Themeable2,
|
||||||
withTheme2,
|
withTheme2,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
import store from 'app/core/store';
|
import store from 'app/core/store';
|
||||||
import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks';
|
import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks';
|
||||||
import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows';
|
import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows';
|
||||||
@@ -74,6 +74,7 @@ import {
|
|||||||
} from '../ContentOutline/ContentOutlineAnalyticEvents';
|
} from '../ContentOutline/ContentOutlineAnalyticEvents';
|
||||||
import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext';
|
import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext';
|
||||||
import { getUrlStateFromPaneState } from '../hooks/useStateSync';
|
import { getUrlStateFromPaneState } from '../hooks/useStateSync';
|
||||||
|
import { parseURL } from '../hooks/useStateSync/parseURL';
|
||||||
import { changePanelState } from '../state/explorePane';
|
import { changePanelState } from '../state/explorePane';
|
||||||
import { changeQueries, runQueries } from '../state/query';
|
import { changeQueries, runQueries } from '../state/query';
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ import { LogsMetaRow } from './LogsMetaRow';
|
|||||||
import LogsNavigation from './LogsNavigation';
|
import LogsNavigation from './LogsNavigation';
|
||||||
import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap';
|
import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap';
|
||||||
import { LogsVolumePanelList } from './LogsVolumePanelList';
|
import { LogsVolumePanelList } from './LogsVolumePanelList';
|
||||||
|
import { migrateLegacyColumns } from './utils/columnMigration';
|
||||||
import { SETTING_KEY_ROOT, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs';
|
import { SETTING_KEY_ROOT, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs';
|
||||||
import { getExploreBaseUrl } from './utils/url';
|
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
|
panelState?.logs?.sortOrder ?? store.get(SETTINGS_KEYS.logsSortOrder) ?? LogsSortOrder.Descending
|
||||||
);
|
);
|
||||||
const [isFlipping, setIsFlipping] = useState<boolean>(false);
|
const [isFlipping, setIsFlipping] = useState<boolean>(false);
|
||||||
const [displayedFields, setDisplayedFields] = useState<string[]>(panelState?.logs?.displayedFields ?? []);
|
|
||||||
const [defaultDisplayedFields, setDefaultDisplayedFields] = useState<string[]>([]);
|
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 [contextOpen, setContextOpen] = useState<boolean>(false);
|
||||||
const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined);
|
const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined);
|
||||||
const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState<PopoverContent>(PINNED_LOGS_MESSAGE);
|
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 logsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const previousLoading = usePrevious(loading);
|
const previousLoading = usePrevious(loading);
|
||||||
|
const { location } = useGrafana();
|
||||||
|
|
||||||
const logsVolumeEventBus = eventBus.newScopedBus('logsvolume', { onlyLocal: false });
|
const logsVolumeEventBus = eventBus.newScopedBus('logsvolume', { onlyLocal: false });
|
||||||
const { register, unregister, outlineItems, updateItem } = useContentOutlineContext() ?? {};
|
const { register, unregister, outlineItems, updateItem } = useContentOutlineContext() ?? {};
|
||||||
@@ -322,7 +326,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
|||||||
dispatch(
|
dispatch(
|
||||||
changePanelState(exploreId, 'logs', {
|
changePanelState(exploreId, 'logs', {
|
||||||
...state.panelsState.logs,
|
...state.panelsState.logs,
|
||||||
columns: logsPanelState.columns ?? panelState?.logs?.columns,
|
|
||||||
visualisationType: logsPanelState.visualisationType ?? visualisationType,
|
visualisationType: logsPanelState.visualisationType ?? visualisationType,
|
||||||
labelFieldName: logsPanelState.labelFieldName,
|
labelFieldName: logsPanelState.labelFieldName,
|
||||||
refId: logsPanelState.refId ?? panelState?.logs?.refId,
|
refId: logsPanelState.refId ?? panelState?.logs?.refId,
|
||||||
@@ -336,7 +339,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
|||||||
[
|
[
|
||||||
dispatch,
|
dispatch,
|
||||||
exploreId,
|
exploreId,
|
||||||
panelState?.logs?.columns,
|
|
||||||
panelState?.logs?.displayedFields,
|
panelState?.logs?.displayedFields,
|
||||||
panelState?.logs?.refId,
|
panelState?.logs?.refId,
|
||||||
panelState?.logs?.tableSortBy,
|
panelState?.logs?.tableSortBy,
|
||||||
@@ -345,14 +347,38 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Migration: Convert legacy 'columns' parameter from URL to 'displayedFields'
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shallowCompare(displayedFields, panelState?.logs?.displayedFields ?? [])) {
|
// Parse URL to check for legacy columns
|
||||||
updatePanelState({
|
const urlParams = location.getSearchObject();
|
||||||
...panelState?.logs,
|
const [urlState] = parseURL(urlParams);
|
||||||
displayedFields,
|
|
||||||
});
|
// 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
|
// actions
|
||||||
const onLogRowHover = useCallback(
|
const onLogRowHover = useCallback(
|
||||||
@@ -541,30 +567,48 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
|||||||
|
|
||||||
const showField = useCallback(
|
const showField = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
const index = displayedFields.indexOf(key);
|
const currentFields = panelState?.logs?.displayedFields ?? [];
|
||||||
|
const index = currentFields.indexOf(key);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
const updatedDisplayedFields = displayedFields.concat(key);
|
const updatedDisplayedFields = currentFields.concat(key);
|
||||||
setDisplayedFields(updatedDisplayedFields);
|
updatePanelState({
|
||||||
|
displayedFields: updatedDisplayedFields,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[displayedFields]
|
[panelState?.logs?.displayedFields, updatePanelState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const hideField = useCallback(
|
const hideField = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
const index = displayedFields.indexOf(key);
|
const currentFields = panelState?.logs?.displayedFields ?? [];
|
||||||
|
const index = currentFields.indexOf(key);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
const updatedDisplayedFields = displayedFields.filter((k) => key !== k);
|
const updatedDisplayedFields = currentFields.filter((k) => key !== k);
|
||||||
setDisplayedFields(updatedDisplayedFields);
|
updatePanelState({
|
||||||
|
displayedFields: updatedDisplayedFields,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[displayedFields]
|
[panelState?.logs?.displayedFields, updatePanelState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearDisplayedFields = useCallback(() => {
|
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>(() => {});
|
const onCloseCallbackRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
@@ -1003,6 +1047,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
|||||||
updatePanelState={updatePanelState}
|
updatePanelState={updatePanelState}
|
||||||
datasourceType={props.datasourceType}
|
datasourceType={props.datasourceType}
|
||||||
displayedFields={displayedFields}
|
displayedFields={displayedFields}
|
||||||
|
defaultDisplayedFields={defaultDisplayedFields}
|
||||||
exploreId={props.exploreId}
|
exploreId={props.exploreId}
|
||||||
absoluteRange={props.absoluteRange}
|
absoluteRange={props.absoluteRange}
|
||||||
logRows={props.logRows}
|
logRows={props.logRows}
|
||||||
@@ -1042,6 +1087,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
|||||||
getFieldLinks={getFieldLinks}
|
getFieldLinks={getFieldLinks}
|
||||||
logsSortOrder={logsSortOrder}
|
logsSortOrder={logsSortOrder}
|
||||||
displayedFields={displayedFields}
|
displayedFields={displayedFields}
|
||||||
|
defaultDisplayedFields={defaultDisplayedFields}
|
||||||
onClickShowField={showField}
|
onClickShowField={showField}
|
||||||
onClickHideField={hideField}
|
onClickHideField={hideField}
|
||||||
app={CoreApp.Explore}
|
app={CoreApp.Explore}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('LogsMetaRow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders the show original line button', () => {
|
it('renders the show original line button', () => {
|
||||||
setup({ displayedFields: ['test'] });
|
setup({ displayedFields: ['test'], defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'] });
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', {
|
screen.getByRole('button', {
|
||||||
name: 'Show original line',
|
name: 'Show original line',
|
||||||
@@ -66,13 +66,20 @@ describe('LogsMetaRow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders the displayed fields', async () => {
|
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();
|
expect(await screen.findByText('testField1234')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a button to clear displayedfields', () => {
|
it('renders a button to clear displayedfields', () => {
|
||||||
const clearSpy = jest.fn();
|
const clearSpy = jest.fn();
|
||||||
setup({ displayedFields: ['testField1234'], clearDisplayedFields: clearSpy });
|
setup({
|
||||||
|
displayedFields: ['testField1234'],
|
||||||
|
defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'],
|
||||||
|
clearDisplayedFields: clearSpy,
|
||||||
|
});
|
||||||
fireEvent(
|
fireEvent(
|
||||||
screen.getByRole('button', {
|
screen.getByRole('button', {
|
||||||
name: 'Show original line',
|
name: 'Show original line',
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels, store } from '@grafana/data';
|
||||||
LogsDedupStrategy,
|
|
||||||
LogsMetaItem,
|
|
||||||
LogsMetaKind,
|
|
||||||
LogRowModel,
|
|
||||||
CoreApp,
|
|
||||||
Labels,
|
|
||||||
store,
|
|
||||||
shallowCompare,
|
|
||||||
} from '@grafana/data';
|
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { config, reportInteraction } from '@grafana/runtime';
|
import { config, reportInteraction } from '@grafana/runtime';
|
||||||
import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
|
import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||||
@@ -58,6 +49,14 @@ export const LogsMetaRow = memo(
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const style = useStyles2(getStyles);
|
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];
|
const logsMetaItem: Array<LogsMetaItem | MetaItemProps> = [...meta];
|
||||||
|
|
||||||
// Add deduplication info
|
// Add deduplication info
|
||||||
@@ -69,16 +68,12 @@ export const LogsMetaRow = memo(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add detected fields info
|
// Add detected fields info - only show when user has added fields beyond defaults
|
||||||
if (
|
if (visualisationType === 'logs' && nonDefaultFields.length > 0) {
|
||||||
visualisationType === 'logs' &&
|
|
||||||
displayedFields?.length > 0 &&
|
|
||||||
shallowCompare(displayedFields, defaultDisplayedFields) === false
|
|
||||||
) {
|
|
||||||
logsMetaItem.push(
|
logsMetaItem.push(
|
||||||
{
|
{
|
||||||
label: t('explore.logs-meta-row.label.showing-only-selected-fields', 'Showing only selected fields'),
|
label: t('explore.logs-meta-row.label.showing-only-selected-fields', 'Showing only selected fields'),
|
||||||
value: <LogLabelsList labels={displayedFields} />,
|
value: <LogLabelsList labels={nonDefaultFields} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '',
|
label: '',
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
useStyles2,
|
useStyles2,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal';
|
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 { DATAPLANE_ID_NAME, LogsFrame } from 'app/features/logs/logsFrame';
|
import { DATAPLANE_ID_NAME, LogsFrame } from 'app/features/logs/logsFrame';
|
||||||
|
|
||||||
import { getFieldLinksForExplore } from '../utils/links';
|
import { getFieldLinksForExplore } from '../utils/links';
|
||||||
@@ -396,9 +398,10 @@ export function getLogsExtractFields(dataFrame: DataFrame) {
|
|||||||
|
|
||||||
function buildLabelFilters(columnsWithMeta: Record<string, FieldNameMeta>) {
|
function buildLabelFilters(columnsWithMeta: Record<string, FieldNameMeta>) {
|
||||||
// Create object of label filters to include columns selected by the user
|
// 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> = {};
|
let labelFilters: Record<string, number> = {};
|
||||||
Object.keys(columnsWithMeta)
|
Object.keys(columnsWithMeta)
|
||||||
.filter((key) => columnsWithMeta[key].active)
|
.filter((key) => columnsWithMeta[key].active && key !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)
|
||||||
.forEach((key) => {
|
.forEach((key) => {
|
||||||
const index = columnsWithMeta[key].index;
|
const index = columnsWithMeta[key].index;
|
||||||
// Index should always be defined for any active column
|
// Index should always be defined for any active column
|
||||||
@@ -433,6 +436,11 @@ function getInitialFieldWidth(field: Field): number | undefined {
|
|||||||
if (field.type === FieldType.time) {
|
if (field.type === FieldType.time) {
|
||||||
return 230;
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export const LogsTableActionButtons = memo((props: Props) => {
|
|||||||
return logRowById?.raw ?? '';
|
return logRowById?.raw ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lineValue = getLineValue();
|
||||||
|
|
||||||
|
// Check if line value is available
|
||||||
|
const isLineValueAvailable = lineValue !== undefined && lineValue !== null && lineValue !== '';
|
||||||
|
|
||||||
const styles = getStyles(theme);
|
const styles = getStyles(theme);
|
||||||
|
|
||||||
// Generate link to the log line
|
// Generate link to the log line
|
||||||
@@ -94,7 +99,9 @@ export const LogsTableActionButtons = memo((props: Props) => {
|
|||||||
}, [absoluteRange, displayedFields, exploreId, logId, logRows, rowIndex, panelState]);
|
}, [absoluteRange, displayedFields, exploreId, logId, logRows, rowIndex, panelState]);
|
||||||
|
|
||||||
const handleViewClick = () => {
|
const handleViewClick = () => {
|
||||||
setIsInspecting(true);
|
if (isLineValueAvailable) {
|
||||||
|
setIsInspecting(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -110,6 +117,7 @@ export const LogsTableActionButtons = memo((props: Props) => {
|
|||||||
name="eye"
|
name="eye"
|
||||||
onClick={handleViewClick}
|
onClick={handleViewClick}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
disabled={!isLineValueAvailable}
|
||||||
/>
|
/>
|
||||||
<ClipboardButton
|
<ClipboardButton
|
||||||
className={styles.icon}
|
className={styles.icon}
|
||||||
@@ -122,6 +130,7 @@ export const LogsTableActionButtons = memo((props: Props) => {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
|
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
|
||||||
getText={getText}
|
getText={getText}
|
||||||
|
disabled={!isLineValueAvailable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isInspecting && (
|
{isInspecting && (
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ describe('LogsTableWrap', () => {
|
|||||||
setup({
|
setup({
|
||||||
panelState: {
|
panelState: {
|
||||||
visualisationType: 'table',
|
visualisationType: 'table',
|
||||||
columns: undefined,
|
displayedFields: undefined,
|
||||||
},
|
},
|
||||||
updatePanelState: updatePanelState,
|
updatePanelState: updatePanelState,
|
||||||
});
|
});
|
||||||
@@ -84,7 +84,7 @@ describe('LogsTableWrap', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updatePanelState).toBeCalledWith({
|
expect(updatePanelState).toBeCalledWith({
|
||||||
visualisationType: 'table',
|
visualisationType: 'table',
|
||||||
columns: { 0: 'app', 1: 'Line', 2: 'Time' },
|
displayedFields: ['app', '___LOG_LINE_BODY___', 'Time'],
|
||||||
labelFieldName: 'labels',
|
labelFieldName: 'labels',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -97,7 +97,7 @@ describe('LogsTableWrap', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updatePanelState).toBeCalledWith({
|
expect(updatePanelState).toBeCalledWith({
|
||||||
visualisationType: 'table',
|
visualisationType: 'table',
|
||||||
columns: { 0: 'Line', 1: 'Time' },
|
displayedFields: ['___LOG_LINE_BODY___', 'Time'],
|
||||||
labelFieldName: 'labels',
|
labelFieldName: 'labels',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -109,7 +109,7 @@ describe('LogsTableWrap', () => {
|
|||||||
setup({
|
setup({
|
||||||
panelState: {
|
panelState: {
|
||||||
visualisationType: 'table',
|
visualisationType: 'table',
|
||||||
columns: undefined,
|
displayedFields: undefined,
|
||||||
},
|
},
|
||||||
updatePanelState: updatePanelState,
|
updatePanelState: updatePanelState,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { Resizable, ResizeCallback } from 're-resizable';
|
import { Resizable, ResizeCallback } from 're-resizable';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DataFrame,
|
DataFrame,
|
||||||
@@ -14,15 +14,23 @@ import {
|
|||||||
store,
|
store,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
AbsoluteTimeRange,
|
AbsoluteTimeRange,
|
||||||
|
shallowCompare,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { reportInteraction } from '@grafana/runtime';
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
import { getDragStyles, InlineField, Select, useStyles2 } from '@grafana/ui';
|
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 {
|
import {
|
||||||
getFieldSelectorWidth,
|
getFieldSelectorWidth,
|
||||||
LogsTableFieldSelector,
|
LogsTableFieldSelector,
|
||||||
MIN_WIDTH,
|
MIN_WIDTH,
|
||||||
} from 'app/features/logs/components/fieldSelector/FieldSelector';
|
} 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 { reportInteractionOnce } from 'app/features/logs/components/panel/analytics';
|
||||||
|
|
||||||
import { parseLogsFrame } from '../../logs/logsFrame';
|
import { parseLogsFrame } from '../../logs/logsFrame';
|
||||||
@@ -44,6 +52,7 @@ interface Props {
|
|||||||
datasourceType?: string;
|
datasourceType?: string;
|
||||||
exploreId?: string;
|
exploreId?: string;
|
||||||
displayedFields?: string[];
|
displayedFields?: string[];
|
||||||
|
defaultDisplayedFields?: string[];
|
||||||
absoluteRange?: AbsoluteTimeRange;
|
absoluteRange?: AbsoluteTimeRange;
|
||||||
logRows?: LogRowModel[];
|
logRows?: LogRowModel[];
|
||||||
}
|
}
|
||||||
@@ -69,10 +78,15 @@ type FieldName = string;
|
|||||||
export type FieldNameMetaStore = Record<FieldName, FieldNameMeta>;
|
export type FieldNameMetaStore = Record<FieldName, FieldNameMeta>;
|
||||||
|
|
||||||
export function LogsTableWrap(props: Props) {
|
export function LogsTableWrap(props: Props) {
|
||||||
const { logsFrames, updatePanelState, panelState } = props;
|
const { logsFrames, updatePanelState, panelState, defaultDisplayedFields } = props;
|
||||||
const propsColumns = panelState?.columns;
|
const propsColumns = panelState?.displayedFields;
|
||||||
// Save the normalized cardinality of each label
|
// Save the normalized cardinality of each label
|
||||||
const [columnsWithMeta, setColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined);
|
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);
|
const dragStyles = useStyles2(getDragStyles);
|
||||||
|
|
||||||
// Filtered copy of columnsWithMeta that only includes matching results
|
// 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]
|
logsFrames.find((f) => f.refId === panelStateRefId) ?? logsFrames[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const logsFrame = useMemo(() => parseLogsFrame(currentDataFrame), [currentDataFrame]);
|
||||||
|
|
||||||
const getColumnsFromProps = useCallback(
|
const getColumnsFromProps = useCallback(
|
||||||
(fieldNames: FieldNameMetaStore) => {
|
(fieldNames: FieldNameMetaStore) => {
|
||||||
const previouslySelected = props.panelState?.columns;
|
const previouslySelected = props.panelState?.displayedFields;
|
||||||
if (previouslySelected) {
|
if (previouslySelected) {
|
||||||
Object.values(previouslySelected).forEach((key, index) => {
|
Object.values(previouslySelected).forEach((key, index) => {
|
||||||
if (fieldNames[key]) {
|
// Map LOG_LINE_BODY_FIELD_NAME to actual body field name
|
||||||
fieldNames[key].active = true;
|
const mappedKey =
|
||||||
fieldNames[key].index = index;
|
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;
|
return fieldNames;
|
||||||
},
|
},
|
||||||
[props.panelState?.columns]
|
[props.panelState?.displayedFields, logsFrame?.bodyField?.name]
|
||||||
);
|
);
|
||||||
|
|
||||||
const logsFrame = useMemo(() => parseLogsFrame(currentDataFrame), [currentDataFrame]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (logsFrame?.timeField.name && logsFrame?.bodyField.name && !propsColumns) {
|
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({
|
updatePanelState({
|
||||||
columns: Object.values(defaultColumns),
|
displayedFields: columns,
|
||||||
visualisationType: 'table',
|
visualisationType: 'table',
|
||||||
labelFieldName: logsFrame?.getLabelFieldName() ?? undefined,
|
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
|
* 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 we have labels and log lines
|
||||||
if (labels?.length && numberOfLogLines) {
|
if (labels?.length && numberOfLogLines) {
|
||||||
|
const displayedFields = props.panelState?.displayedFields ?? [];
|
||||||
// Iterate through all of Labels
|
// Iterate through all of Labels
|
||||||
labels.forEach((labels: Labels) => {
|
labels.forEach((labels: Labels) => {
|
||||||
const labelsArray = Object.keys(labels);
|
const labelsArray = Object.keys(labels);
|
||||||
@@ -196,11 +218,19 @@ export function LogsTableWrap(props: Props) {
|
|||||||
if (labelCardinality.has(label)) {
|
if (labelCardinality.has(label)) {
|
||||||
const value = labelCardinality.get(label);
|
const value = labelCardinality.get(label);
|
||||||
if (value) {
|
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, {
|
labelCardinality.set(label, {
|
||||||
percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1,
|
percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1,
|
||||||
active: true,
|
active: true,
|
||||||
index: value.index,
|
index: index,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
labelCardinality.set(label, {
|
labelCardinality.set(label, {
|
||||||
@@ -212,7 +242,25 @@ export function LogsTableWrap(props: Props) {
|
|||||||
}
|
}
|
||||||
// Otherwise add it
|
// Otherwise add it
|
||||||
} else {
|
} 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
|
// Normalize the other fields
|
||||||
|
const displayedFields = props.panelState?.displayedFields ?? [];
|
||||||
otherFields.forEach((field) => {
|
otherFields.forEach((field) => {
|
||||||
const isActive = pendingLabelState[field.name]?.active;
|
// Check displayedFields first, then fall back to current columnsWithMeta
|
||||||
const index = pendingLabelState[field.name]?.index;
|
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) {
|
if (isActive && index !== undefined) {
|
||||||
pendingLabelState[field.name] = {
|
pendingLabelState[field.name] = {
|
||||||
percentOfLinesWithLabel: normalize(
|
percentOfLinesWithLabel: normalize(
|
||||||
@@ -274,10 +327,13 @@ export function LogsTableWrap(props: Props) {
|
|||||||
pendingLabelState[logsFrame.timeField.name].type = 'TIME_FIELD';
|
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
|
// 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 [sidebarWidth, setSidebarWidth] = useState(getFieldSelectorWidth(SETTING_KEY_ROOT));
|
||||||
const tableWidth = props.width - sidebarWidth;
|
const tableWidth = props.width - sidebarWidth;
|
||||||
@@ -323,17 +379,33 @@ export function LogsTableWrap(props: Props) {
|
|||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
const pendingLabelState = { ...columnsWithMeta };
|
const pendingLabelState = { ...columnsWithMeta };
|
||||||
Object.keys(pendingLabelState).forEach((key) => {
|
Object.keys(pendingLabelState).forEach((key) => {
|
||||||
const isDefaultField = !!pendingLabelState[key].type;
|
const field = pendingLabelState[key];
|
||||||
// after reset the only active fields are the special time and body fields
|
const isTimeField = field.type === 'TIME_FIELD' || key === TABLE_TIME_FIELD_NAME;
|
||||||
pendingLabelState[key].active = isDefaultField ? true : false;
|
const isBodyField = field.type === 'BODY_FIELD' || key === TABLE_LINE_FIELD_NAME;
|
||||||
// reset the index
|
const isDetectedLevel = key === TABLE_DETECTED_LEVEL_FIELD_NAME;
|
||||||
if (pendingLabelState[key].type === 'TIME_FIELD') {
|
|
||||||
pendingLabelState[key].index = 0;
|
// 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 {
|
} else {
|
||||||
pendingLabelState[key].index = pendingLabelState[key].type === 'BODY_FIELD' ? 1 : undefined;
|
pendingLabelState[key].active = false;
|
||||||
|
pendingLabelState[key].index = undefined;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setColumnsWithMeta(pendingLabelState);
|
setColumnsWithMeta(pendingLabelState);
|
||||||
|
// Reset displayedFields to defaults
|
||||||
|
updatePanelState({
|
||||||
|
displayedFields: defaultDisplayedFields?.length ? defaultDisplayedFields : [],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const reorderColumn = (newColumns: string[]) => {
|
const reorderColumn = (newColumns: string[]) => {
|
||||||
@@ -364,17 +436,29 @@ export function LogsTableWrap(props: Props) {
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const newColumns: Record<number, string> = Object.assign(
|
// Map body field name to LOG_LINE_BODY_FIELD_NAME
|
||||||
{},
|
const bodyFieldName = logsFrame?.bodyField?.name ?? TABLE_LINE_FIELD_NAME;
|
||||||
// Get the keys of the object as an array
|
const bodyFieldIndex = newColumnsArray.indexOf(bodyFieldName);
|
||||||
newColumnsArray
|
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 = {
|
const newPanelState: ExploreLogsPanelState = {
|
||||||
...props.panelState,
|
...props.panelState,
|
||||||
// URL format requires our array of values be an object, so we convert it using object.assign
|
// 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,
|
refId: currentDataFrame.refId,
|
||||||
visualisationType: 'table',
|
visualisationType: 'table',
|
||||||
labelFieldName: logsFrame?.getLabelFieldName() ?? undefined,
|
labelFieldName: logsFrame?.getLabelFieldName() ?? undefined,
|
||||||
@@ -447,7 +531,6 @@ export function LogsTableWrap(props: Props) {
|
|||||||
|
|
||||||
setFilteredColumnsWithMeta(pendingFilteredLabelState);
|
setFilteredColumnsWithMeta(pendingFilteredLabelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateExploreState(pendingLabelState);
|
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 { t } from '@grafana/i18n';
|
||||||
import { DataQuery, DataSourceRef, Panel } from '@grafana/schema';
|
import { DataQuery, DataSourceRef, Panel } from '@grafana/schema';
|
||||||
import { DataTransformerConfig } from '@grafana/schema/dist/esm/raw/dashboard/x/dashboard_types.gen';
|
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';
|
import { ExplorePanelData } from 'app/types/explore';
|
||||||
|
|
||||||
interface ExploreToDashboardPanelOptions {
|
interface ExploreToDashboardPanelOptions {
|
||||||
@@ -24,7 +26,7 @@ function getLogsTableTransformations(
|
|||||||
options: ExploreToDashboardPanelOptions
|
options: ExploreToDashboardPanelOptions
|
||||||
): DataTransformerConfig[] {
|
): DataTransformerConfig[] {
|
||||||
let transformations: 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 we have a labels column, we need to extract the fields from it
|
||||||
if (options.panelState.logs?.labelFieldName) {
|
if (options.panelState.logs?.labelFieldName) {
|
||||||
transformations.push({
|
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
|
// Show the columns that the user selected in explore
|
||||||
transformations.push({
|
transformations.push({
|
||||||
id: 'organize',
|
id: 'organize',
|
||||||
options: {
|
options: {
|
||||||
indexByName: Object.values(options.panelState.logs.columns).reduce(
|
indexByName: mappedDisplayedFields.reduce(
|
||||||
(acc: Record<string, number>, value: string, idx) => ({
|
(acc: Record<string, number>, value: string, idx) => ({
|
||||||
...acc,
|
...acc,
|
||||||
[value]: idx,
|
[value]: idx,
|
||||||
}),
|
}),
|
||||||
{}
|
{}
|
||||||
),
|
),
|
||||||
includeByName: Object.values(options.panelState.logs.columns).reduce(
|
includeByName: mappedDisplayedFields.reduce(
|
||||||
(acc: Record<string, boolean>, value: string) => ({
|
(acc: Record<string, boolean>, value: string) => ({
|
||||||
...acc,
|
...acc,
|
||||||
[value]: true,
|
[value]: true,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
|
|||||||
width?: number;
|
width?: number;
|
||||||
logsTableFrames?: DataFrame[];
|
logsTableFrames?: DataFrame[];
|
||||||
displayedFields?: string[];
|
displayedFields?: string[];
|
||||||
|
defaultDisplayedFields?: string[];
|
||||||
exploreId?: string;
|
exploreId?: string;
|
||||||
absoluteRange?: AbsoluteTimeRange;
|
absoluteRange?: AbsoluteTimeRange;
|
||||||
logRows?: LogRowModel[];
|
logRows?: LogRowModel[];
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const ControlledLogsTable = ({
|
|||||||
logsTableFrames,
|
logsTableFrames,
|
||||||
visualisationType,
|
visualisationType,
|
||||||
displayedFields,
|
displayedFields,
|
||||||
|
defaultDisplayedFields,
|
||||||
exploreId,
|
exploreId,
|
||||||
absoluteRange,
|
absoluteRange,
|
||||||
logRows,
|
logRows,
|
||||||
@@ -63,6 +64,7 @@ export const ControlledLogsTable = ({
|
|||||||
updatePanelState={updatePanelState}
|
updatePanelState={updatePanelState}
|
||||||
datasourceType={datasourceType}
|
datasourceType={datasourceType}
|
||||||
displayedFields={displayedFields}
|
displayedFields={displayedFields}
|
||||||
|
defaultDisplayedFields={defaultDisplayedFields}
|
||||||
exploreId={exploreId}
|
exploreId={exploreId}
|
||||||
absoluteRange={absoluteRange}
|
absoluteRange={absoluteRange}
|
||||||
logRows={logRows}
|
logRows={logRows}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => {
|
|||||||
|
|
||||||
export const LOG_LINE_BODY_FIELD_NAME = '___LOG_LINE_BODY___';
|
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) => {
|
export const LogDetailsBody = (props: Props) => {
|
||||||
const showField = () => {
|
const showField = () => {
|
||||||
const { onClickShowField, row } = props;
|
const { onClickShowField, row } = props;
|
||||||
|
|||||||
@@ -21,22 +21,6 @@ interface Props {
|
|||||||
export const ActiveFields = ({ activeFields, clear, fields, reorder, suggestedFields, toggle }: Props) => {
|
export const ActiveFields = ({ activeFields, clear, fields, reorder, suggestedFields, toggle }: Props) => {
|
||||||
const styles = useStyles2(getLogsFieldsStyles);
|
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(
|
const active = useMemo(
|
||||||
() => [
|
() => [
|
||||||
...activeFields
|
...activeFields
|
||||||
@@ -48,6 +32,47 @@ export const ActiveFields = ({ activeFields, clear, fields, reorder, suggestedFi
|
|||||||
[activeFields, fields, suggestedFields]
|
[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(
|
const suggested = useMemo(
|
||||||
() => suggestedFields.filter((suggestedField) => !activeFields.includes(suggestedField.name)),
|
() => suggestedFields.filter((suggestedField) => !activeFields.includes(suggestedField.name)),
|
||||||
[activeFields, suggestedFields]
|
[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 { SETTING_KEY_ROOT } from 'app/features/explore/Logs/utils/logs';
|
||||||
import { parseLogsFrame } from 'app/features/logs/logsFrame';
|
import { parseLogsFrame } from 'app/features/logs/logsFrame';
|
||||||
|
|
||||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
import { LOG_LINE_BODY_FIELD_NAME, TABLE_DETECTED_LEVEL_FIELD_NAME } from '../LogDetailsBody';
|
||||||
import { getSuggestedFieldsForLogs } from '../otel/formats';
|
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, getSuggestedFieldsForLogs } from '../otel/formats';
|
||||||
import { useLogListContext } from '../panel/LogListContext';
|
import { useLogListContext } from '../panel/LogListContext';
|
||||||
import { reportInteractionOnce } from '../panel/analytics';
|
import { reportInteractionOnce } from '../panel/analytics';
|
||||||
import { LogListModel } from '../panel/processing';
|
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 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) {
|
if (!onClickShowField || !onClickHideField || !setDisplayedFields) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -215,7 +218,7 @@ export const LogsTableFieldSelector = ({
|
|||||||
const displayedColumns = useMemo(
|
const displayedColumns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.keys(columnsWithMeta)
|
Object.keys(columnsWithMeta)
|
||||||
.filter((column) => columnsWithMeta[column].active)
|
.filter((column) => columnsWithMeta[column].active && column !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
columnsWithMeta[a].index !== undefined && columnsWithMeta[b].index !== undefined
|
columnsWithMeta[a].index !== undefined && columnsWithMeta[b].index !== undefined
|
||||||
? columnsWithMeta[a].index - columnsWithMeta[b].index
|
? columnsWithMeta[a].index - columnsWithMeta[b].index
|
||||||
@@ -250,7 +253,10 @@ export const LogsTableFieldSelector = ({
|
|||||||
() => getSuggestedFields(logs, displayedColumns, defaultColumns),
|
() => getSuggestedFields(logs, displayedColumns, defaultColumns),
|
||||||
[defaultColumns, displayedColumns, logs]
|
[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 ? (
|
return sidebarWidth > MIN_WIDTH * 2 ? (
|
||||||
<FieldSelector
|
<FieldSelector
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { findHighlightChunksInText, GrafanaTheme2, LogsDedupStrategy, TimeRange
|
|||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { Button, Icon, Tooltip } from '@grafana/ui';
|
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 { LogLabels } from '../LogLabels';
|
||||||
import { LogMessageAnsi } from '../LogMessageAnsi';
|
import { LogMessageAnsi } from '../LogMessageAnsi';
|
||||||
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
|
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
|
||||||
@@ -392,6 +392,10 @@ const DisplayedFields = ({
|
|||||||
if (field === LOG_LINE_BODY_FIELD_NAME) {
|
if (field === LOG_LINE_BODY_FIELD_NAME) {
|
||||||
return <LogLineBody log={log} key={field} styles={styles} />;
|
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) {
|
if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME && syntaxHighlighting) {
|
||||||
return (
|
return (
|
||||||
<span className="field log-syntax-highlight" title={getNormalizedFieldName(field)} key={field}>
|
<span className="field log-syntax-highlight" title={getNormalizedFieldName(field)} key={field}>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { config, reportInteraction } from '@grafana/runtime';
|
import { config, reportInteraction } from '@grafana/runtime';
|
||||||
|
|
||||||
import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
|
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 { createLogLine, createLogRow } from '../mocks/logRow';
|
||||||
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats';
|
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats';
|
||||||
|
|
||||||
@@ -125,12 +125,32 @@ describe('LogList', () => {
|
|||||||
const onLogOptionsChange = jest.fn();
|
const onLogOptionsChange = jest.fn();
|
||||||
const setDisplayedFields = 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(
|
render(
|
||||||
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
|
<LogList
|
||||||
|
{...defaultProps}
|
||||||
|
logs={logsWithDetectedLevel}
|
||||||
|
onLogOptionsChange={onLogOptionsChange}
|
||||||
|
setDisplayedFields={setDisplayedFields}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||||
expect(onLogOptionsChange).not.toHaveBeenCalled();
|
// Even when OTel is disabled, we still report table defaults
|
||||||
expect(setDisplayedFields).not.toHaveBeenCalled();
|
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;
|
config.featureToggles.otelLogsFormatting = originalState;
|
||||||
});
|
});
|
||||||
@@ -140,14 +160,33 @@ describe('LogList', () => {
|
|||||||
const onLogOptionsChange = jest.fn();
|
const onLogOptionsChange = jest.fn();
|
||||||
const setDisplayedFields = 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(
|
render(
|
||||||
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
|
<LogList
|
||||||
|
{...defaultProps}
|
||||||
|
logs={logsWithDetectedLevel}
|
||||||
|
onLogOptionsChange={onLogOptionsChange}
|
||||||
|
setDisplayedFields={setDisplayedFields}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
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
|
// setDisplayedFields is called with the default fields
|
||||||
expect(setDisplayedFields).not.toHaveBeenCalled();
|
expect(setDisplayedFields).toHaveBeenCalledWith([
|
||||||
|
TABLE_TIME_FIELD_NAME,
|
||||||
|
TABLE_DETECTED_LEVEL_FIELD_NAME,
|
||||||
|
LOG_LINE_BODY_FIELD_NAME,
|
||||||
|
]);
|
||||||
|
|
||||||
config.featureToggles.otelLogsFormatting = originalState;
|
config.featureToggles.otelLogsFormatting = originalState;
|
||||||
});
|
});
|
||||||
@@ -157,7 +196,12 @@ describe('LogList', () => {
|
|||||||
const onLogOptionsChange = jest.fn();
|
const onLogOptionsChange = jest.fn();
|
||||||
const setDisplayedFields = 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(
|
render(
|
||||||
<LogList
|
<LogList
|
||||||
@@ -168,11 +212,19 @@ describe('LogList', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||||
|
// For OTel logs, we report table defaults + OTel fields
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
|
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,
|
LOG_LINE_BODY_FIELD_NAME,
|
||||||
OTEL_LOG_LINE_ATTRIBUTES_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;
|
config.featureToggles.otelLogsFormatting = originalState;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ import { config, getDataSourceSrv } from '@grafana/runtime';
|
|||||||
import { PopoverContent } from '@grafana/ui';
|
import { PopoverContent } from '@grafana/ui';
|
||||||
|
|
||||||
import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils';
|
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 { getFieldSelectorState } from '../fieldSelector/FieldSelector';
|
||||||
import { getDisplayedFieldsForLogs } from '../otel/formats';
|
import { getDisplayedFieldsForLogs } from '../otel/formats';
|
||||||
|
|
||||||
@@ -118,6 +124,27 @@ export const useLogIsPermalinked = (log: LogListModel) => {
|
|||||||
return permalinkedLogId && permalinkedLogId === log.uid;
|
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<
|
export type LogListState = Pick<
|
||||||
LogListContextData,
|
LogListContextData,
|
||||||
| 'dedupStrategy'
|
| 'dedupStrategy'
|
||||||
@@ -263,27 +290,52 @@ export const LogListContextProvider = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const otelDisplayedFields = useMemo(() => {
|
const otelDisplayedFields = useMemo(() => {
|
||||||
if (!config.featureToggles.otelLogsFormatting || !setDisplayedFields || showLogAttributes === false) {
|
if (!config.featureToggles.otelLogsFormatting) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (showLogAttributes === false) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return getDisplayedFieldsForLogs(logs);
|
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(() => {
|
useEffect(() => {
|
||||||
if (config.featureToggles.otelLogsFormatting && showLogAttributes !== false) {
|
if (defaultDisplayedFields.length > 0) {
|
||||||
onLogOptionsChange?.('defaultDisplayedFields', otelDisplayedFields);
|
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(() => {
|
useEffect(() => {
|
||||||
if (displayedFields.length > 0 || !setDisplayedFields) {
|
if (!setDisplayedFields || defaultDisplayedFields.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (otelDisplayedFields.length) {
|
|
||||||
setDisplayedFields(otelDisplayedFields);
|
if (displayedFields.length === 0) {
|
||||||
|
setDisplayedFields(defaultDisplayedFields);
|
||||||
}
|
}
|
||||||
}, [displayedFields.length, otelDisplayedFields, setDisplayedFields]);
|
}, [displayedFields, defaultDisplayedFields, tableDefaultFields, setDisplayedFields]);
|
||||||
|
|
||||||
// Sync state
|
// Sync state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -223,6 +223,8 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onLogOptionsChange.mockClear();
|
||||||
await userEvent.click(screen.getByLabelText(OLDEST_LOGS_LABEL_REGEX));
|
await userEvent.click(screen.getByLabelText(OLDEST_LOGS_LABEL_REGEX));
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('sortOrder', LogsSortOrder.Descending);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('sortOrder', LogsSortOrder.Descending);
|
||||||
@@ -235,6 +237,8 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onLogOptionsChange.mockClear();
|
||||||
await userEvent.click(screen.getByLabelText(DEDUPE_LABEL_COPY));
|
await userEvent.click(screen.getByLabelText(DEDUPE_LABEL_COPY));
|
||||||
await userEvent.click(screen.getByText('Numbers'));
|
await userEvent.click(screen.getByText('Numbers'));
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
@@ -286,6 +290,8 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onLogOptionsChange.mockClear();
|
||||||
await userEvent.click(screen.getByLabelText(SHOW_TIMESTAMP_LABEL_COPY));
|
await userEvent.click(screen.getByLabelText(SHOW_TIMESTAMP_LABEL_COPY));
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
|
||||||
@@ -298,6 +304,8 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onLogOptionsChange.mockClear();
|
||||||
await userEvent.click(screen.getByLabelText(WRAP_LINES_LABEL_COPY));
|
await userEvent.click(screen.getByLabelText(WRAP_LINES_LABEL_COPY));
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
|
||||||
@@ -319,6 +327,8 @@ describe('LogListControls', () => {
|
|||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onLogOptionsChange.mockClear();
|
||||||
|
|
||||||
await userEvent.click(screen.getByLabelText('Wrap disabled'));
|
await userEvent.click(screen.getByLabelText('Wrap disabled'));
|
||||||
await userEvent.click(screen.getByText('Enable line wrapping'));
|
await userEvent.click(screen.getByText('Enable line wrapping'));
|
||||||
|
|
||||||
@@ -354,6 +364,8 @@ describe('LogListControls', () => {
|
|||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onLogOptionsChange.mockClear();
|
||||||
|
|
||||||
await userEvent.click(screen.getByLabelText(TIMESTAMP_LABEL_COPY));
|
await userEvent.click(screen.getByLabelText(TIMESTAMP_LABEL_COPY));
|
||||||
await userEvent.click(screen.getByText('Show millisecond timestamps'));
|
await userEvent.click(screen.getByText('Show millisecond timestamps'));
|
||||||
|
|
||||||
@@ -381,6 +393,8 @@ describe('LogListControls', () => {
|
|||||||
<LogListControls eventBus={new EventBusSrv()} />
|
<LogListControls eventBus={new EventBusSrv()} />
|
||||||
</LogListContextProvider>
|
</LogListContextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onLogOptionsChange.mockClear();
|
||||||
await userEvent.click(screen.getByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY));
|
await userEvent.click(screen.getByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY));
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true);
|
expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true);
|
||||||
|
|||||||
@@ -156,30 +156,34 @@ export const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => {
|
|||||||
return 0;
|
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 => {
|
export const sortLogsResult = (logsResult: LogsModel | null, sortOrder: LogsSortOrder): LogsModel => {
|
||||||
const rows = logsResult ? sortLogRows(logsResult.rows, sortOrder) : [];
|
const rows = logsResult ? sortLogRows(logsResult.rows, sortOrder) : [];
|
||||||
return logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows };
|
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
|
// 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__;
|
return logRow.labels.__error__;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const checkLogsSampled = (logRow: LogRowModel): string | undefined => {
|
export function checkLogsSampled(logRow: LogRowModel): string | undefined {
|
||||||
if (!logRow.labels.__adaptive_logs_sampled__) {
|
if (!logRow.labels.__adaptive_logs_sampled__) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return logRow.labels.__adaptive_logs_sampled__ === 'true'
|
return logRow.labels.__adaptive_logs_sampled__ === 'true'
|
||||||
? 'Logs like this one have been dropped by Adaptive Logs'
|
? '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`;
|
: `${logRow.labels.__adaptive_logs_sampled__}% of logs like this one have been dropped by Adaptive Logs`;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const escapeUnescapedString = (string: string) =>
|
export function escapeUnescapedString(string: string) {
|
||||||
string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n'));
|
return string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n'));
|
||||||
|
}
|
||||||
|
|
||||||
export function logRowsToReadableJson(logs: LogRowModel[], pickFields: string[] = []) {
|
export function logRowsToReadableJson(logs: LogRowModel[], pickFields: string[] = []) {
|
||||||
return logs.map((log) => {
|
return logs.map((log) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user