Compare commits

..
Author SHA1 Message Date
L2D2Grafana 7d3ac161bc Logs: yarn i18n-extract 2026-01-07 08:27:54 -08:00
L2D2Grafana 50635853c8 Logs: hide time in the logs panel view 2026-01-07 07:50:56 -08:00
L2D2Grafana fe985eb5d0 Logs: remove test comments 2026-01-07 07:50:56 -08:00
L2D2Grafana c037e39a1a Logs: update activeField comments 2026-01-07 07:50:55 -08:00
L2D2Grafana 2168453109 Logs: i18n 2026-01-07 07:50:55 -08:00
L2D2Grafana f04118e6c7 Logs: update to latest main 2026-01-07 07:50:55 -08:00
L2D2Grafana ddfca17d92 Logs: update tests 2026-01-07 07:50:55 -08:00
L2D2Grafana fa9cc6bc8c Logs: only run the url migration if columns are present 2026-01-07 07:50:54 -08:00
L2D2Grafana a196cc1adb Logs: displayFields migration us columns for vistype table 2026-01-07 07:50:54 -08:00
L2D2Grafana 70d0e9006b Logs: fix logs table reset 2026-01-07 07:50:51 -08:00
L2D2Grafana e8561c6b0f Logs: disable link to log line when log line is not there 2026-01-07 07:50:07 -08:00
L2D2Grafana 731704fa21 Logs: fix detected_level column width 2026-01-07 07:43:03 -08:00
L2D2Grafana 6b543d22f9 Logs: remove false 2026-01-07 07:43:03 -08:00
L2D2Grafana c234f41691 Logs: use refId 2026-01-07 07:43:02 -08:00
L2D2Grafana 86a12469a7 Logs: abstract url migration and add unit tests 2026-01-07 07:43:02 -08:00
L2D2Grafana e538c2f3c9 Logs: fix e2e tests 2026-01-07 07:43:02 -08:00
L2D2Grafana 0a3892a52e Logs: fix tests 2026-01-07 07:43:01 -08:00
L2D2Grafana dc20e8bb2b Logs: fix tests 2026-01-07 07:43:01 -08:00
L2D2Grafana 099c75b632 Logs: update tests, check for detected_level 2026-01-07 07:43:01 -08:00
L2D2Grafana 4de55500d8 Logs: table detected_level width 2026-01-07 07:43:00 -08:00
L2D2Grafana 05319502a0 Logs: migration of columns in the url, update tests 2026-01-07 07:43:00 -08:00
L2D2Grafana aacaf0ca26 Logs: update show original line with defaults 2026-01-07 07:43:00 -08:00
L2D2Grafana 1e841eaba8 Logs: use displayFields for both logs panel adn table 2026-01-07 07:42:56 -08:00
Matheus Macabu 2efcc88e62 FeatureToggles: Remove unused kubernetesFeatureToggles (#115933) 2026-01-07 15:53:58 +01:00
Galen Kistler 6fea614106 LogsTable: Inspect button fix (#115912)
* fix: inspect button

* chore: memoize component
2026-01-07 14:31:04 +00:00
antonio c0c05a65fd docs/alerting: add video to tutorial (#115675) 2026-01-07 15:11:41 +01:00
Alexander AkhmetovandKonrad Lalik 41ed2aeb23 Alerting: Display change message next to the rule version when exists (#115664)
* Alerting: Display change message next to the rule version when exists

* Alerting: Update version history tests for message field

Updates test mocks and assertions to include message fields in version
history data. Adds three message examples to the mock handler and updates
test expectations to verify the Notes column displays correctly when
messages are present or absent.

---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
2026-01-07 15:06:41 +01:00
Johnny Kartheiser 9e9233051e alerting docs: saved searches (#115524)
* alerting docs: saved searches

adds paragraph about saved searches functionality

* typo and explainer

details on default search option

* image update
2026-01-07 08:03:38 -06:00
Ricardo Galeno a5faedbe68 Explore: escape character of break-line in Traceql in Search tab fixing an issue when filtering by a multi line span tag value (#114672)
* Explore: escape character of break-line in Traceql in Search tab

* Explore: fix test for escape character of break-line in Traceql in Search tab
2026-01-07 13:31:29 +01:00
40 changed files with 1463 additions and 252 deletions
@@ -41,9 +41,13 @@ Select a group to expand it and view the list of alert rules within that group.
The list view includes a number of filters to simplify managing large volumes of alerts.
## Filter and save searches
Click the **Filter** button to open the filter popup. You can filter by name, label, folder/namespace, evaluation group, data source, contact point, rule source, rule state, rule type, and the health of the alert rule from the popup menu. Click **Apply** at the bottom of the filter popup to enact the filters as you search.
{{< figure src="/media/docs/alerting/alerting-list-view-filter.png" max-width="750px" alt="Alert rule filter options" >}}
Click the **Saved searches** button to open the list of previously saved searches, or click **+ Save current search** to add your current search to the saved searches list. You can also rename a saved search or set it as a default search. When you set a saved search as the default search, the Alert rules page opens with the search applied.
{{< figure src="/media/docs/alerting/alerting-saved-searches.png" max-width="750px" alt="Alert rule filter options" >}}
## Change alert rules list view
@@ -23,6 +23,8 @@ killercoda:
This tutorial is a continuation of the [Get started with Grafana Alerting - Route alerts using dynamic labels](http://www.grafana.com/tutorials/alerting-get-started-pt5/) tutorial.
{{< youtube id="mqj_hN24zLU" >}}
<!-- USE CASE -->
In this tutorial you will learn how to:
@@ -78,7 +78,6 @@ export interface ExploreTracePanelState {
export interface ExploreLogsPanelState {
id?: string;
columns?: Record<number, string>;
visualisationType?: 'table' | 'logs';
labelFieldName?: string;
// Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized
-4
View File
@@ -400,10 +400,6 @@ export interface FeatureToggles {
*/
tableSharedCrosshair?: boolean;
/**
* Use the kubernetes API for feature toggle management in the frontend
*/
kubernetesFeatureToggles?: boolean;
/**
* Enabled grafana cloud specific RBAC roles
*/
cloudRBACRoles?: boolean;
@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react';
import { VizLegendTable } from './VizLegendTable';
import { VizLegendItem } from './types';
describe('VizLegendTable', () => {
const mockItems: VizLegendItem[] = [
{ label: 'Series 1', color: 'red', yAxis: 1 },
{ label: 'Series 2', color: 'blue', yAxis: 1 },
{ label: 'Series 3', color: 'green', yAxis: 1 },
];
it('renders without crashing', () => {
const { container } = render(<VizLegendTable items={mockItems} placement="bottom" />);
expect(container.querySelector('table')).toBeInTheDocument();
});
it('renders all items', () => {
render(<VizLegendTable items={mockItems} placement="bottom" />);
expect(screen.getByText('Series 1')).toBeInTheDocument();
expect(screen.getByText('Series 2')).toBeInTheDocument();
expect(screen.getByText('Series 3')).toBeInTheDocument();
});
it('renders table headers when items have display values', () => {
const itemsWithStats: VizLegendItem[] = [
{
label: 'Series 1',
color: 'red',
yAxis: 1,
getDisplayValues: () => [
{ numeric: 100, text: '100', title: 'Max' },
{ numeric: 50, text: '50', title: 'Min' },
],
},
];
render(<VizLegendTable items={itemsWithStats} placement="bottom" />);
expect(screen.getByText('Max')).toBeInTheDocument();
expect(screen.getByText('Min')).toBeInTheDocument();
});
it('renders sort icon when sorted', () => {
const { container } = render(
<VizLegendTable items={mockItems} placement="bottom" sortBy="Name" sortDesc={false} />
);
expect(container.querySelector('svg')).toBeInTheDocument();
});
it('calls onToggleSort when header is clicked', () => {
const onToggleSort = jest.fn();
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={true} />);
const header = screen.getByText('Name');
header.click();
expect(onToggleSort).toHaveBeenCalledWith('Name');
});
it('does not call onToggleSort when not sortable', () => {
const onToggleSort = jest.fn();
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={false} />);
const header = screen.getByText('Name');
header.click();
expect(onToggleSort).not.toHaveBeenCalled();
});
it('renders with long labels', () => {
const itemsWithLongLabels: VizLegendItem[] = [
{
label: 'This is a very long series name that should be scrollable within its table cell',
color: 'red',
yAxis: 1,
},
];
render(<VizLegendTable items={itemsWithLongLabels} placement="bottom" />);
expect(
screen.getByText('This is a very long series name that should be scrollable within its table cell')
).toBeInTheDocument();
});
});
@@ -119,7 +119,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
table: css({
width: '100%',
'th:first-child': {
width: '100%',
borderBottom: `1px solid ${theme.colors.border.weak}`,
},
}),
@@ -0,0 +1,112 @@
import { render, screen } from '@testing-library/react';
import { LegendTableItem } from './VizLegendTableItem';
import { VizLegendItem } from './types';
describe('LegendTableItem', () => {
const mockItem: VizLegendItem = {
label: 'Series 1',
color: 'red',
yAxis: 1,
};
it('renders without crashing', () => {
const { container } = render(
<table>
<tbody>
<LegendTableItem item={mockItem} />
</tbody>
</table>
);
expect(container.querySelector('tr')).toBeInTheDocument();
});
it('renders label text', () => {
render(
<table>
<tbody>
<LegendTableItem item={mockItem} />
</tbody>
</table>
);
expect(screen.getByText('Series 1')).toBeInTheDocument();
});
it('renders with long label text', () => {
const longLabelItem: VizLegendItem = {
...mockItem,
label: 'This is a very long series name that should be scrollable in the table cell',
};
render(
<table>
<tbody>
<LegendTableItem item={longLabelItem} />
</tbody>
</table>
);
expect(
screen.getByText('This is a very long series name that should be scrollable in the table cell')
).toBeInTheDocument();
});
it('renders stat values when provided', () => {
const itemWithStats: VizLegendItem = {
...mockItem,
getDisplayValues: () => [
{ numeric: 100, text: '100', title: 'Max' },
{ numeric: 50, text: '50', title: 'Min' },
],
};
render(
<table>
<tbody>
<LegendTableItem item={itemWithStats} />
</tbody>
</table>
);
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText('50')).toBeInTheDocument();
});
it('renders right y-axis indicator when yAxis is 2', () => {
const rightAxisItem: VizLegendItem = {
...mockItem,
yAxis: 2,
};
render(
<table>
<tbody>
<LegendTableItem item={rightAxisItem} />
</tbody>
</table>
);
expect(screen.getByText('(right y-axis)')).toBeInTheDocument();
});
it('calls onLabelClick when label is clicked', () => {
const onLabelClick = jest.fn();
render(
<table>
<tbody>
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} />
</tbody>
</table>
);
const button = screen.getByRole('button');
button.click();
expect(onLabelClick).toHaveBeenCalledWith(mockItem, expect.any(Object));
});
it('does not call onClick when readonly', () => {
const onLabelClick = jest.fn();
render(
<table>
<tbody>
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} readonly={true} />
</tbody>
</table>
);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});
});
@@ -69,7 +69,7 @@ export const LegendTableItem = ({
return (
<tr className={cx(styles.row, className)}>
<td>
<td className={styles.labelCell}>
<span className={styles.itemWrapper}>
<VizLegendSeriesIcon
color={item.color}
@@ -77,24 +77,26 @@ export const LegendTableItem = ({
readonly={readonly}
lineStyle={item.lineStyle}
/>
<button
disabled={readonly}
type="button"
title={item.label}
onBlur={onMouseOut}
onFocus={onMouseOver}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onClick={!readonly ? onClick : undefined}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label}{' '}
{item.yAxis === 2 && (
<span className={styles.yAxisLabel}>
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
</span>
)}
</button>
<div className={styles.labelCellInner}>
<button
disabled={readonly}
type="button"
title={item.label}
onBlur={onMouseOut}
onFocus={onMouseOver}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onClick={!readonly ? onClick : undefined}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label}{' '}
{item.yAxis === 2 && (
<span className={styles.yAxisLabel}>
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
</span>
)}
</button>
</div>
</span>
</td>
{item.getDisplayValues &&
@@ -128,6 +130,27 @@ const getStyles = (theme: GrafanaTheme2) => {
background: rowHoverBg,
},
}),
labelCell: css({
label: 'LegendLabelCell',
maxWidth: 0,
width: '100%',
}),
labelCellInner: css({
label: 'LegendLabelCellInner',
display: 'block',
flex: 1,
minWidth: 0,
overflowX: 'auto',
overflowY: 'hidden',
paddingRight: theme.spacing(3),
scrollbarWidth: 'none',
msOverflowStyle: 'none',
maskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
'&::-webkit-scrollbar': {
display: 'none',
},
}),
label: css({
label: 'LegendLabel',
whiteSpace: 'nowrap',
@@ -135,9 +158,6 @@ const getStyles = (theme: GrafanaTheme2) => {
border: 'none',
fontSize: 'inherit',
padding: 0,
maxWidth: '600px',
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'text',
}),
labelDisabled: css({
-7
View File
@@ -650,13 +650,6 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaDatavizSquad,
},
{
Name: "kubernetesFeatureToggles",
Description: "Use the kubernetes API for feature toggle management in the frontend",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaOperatorExperienceSquad,
},
{
Name: "cloudRBACRoles",
Description: "Enabled grafana cloud specific RBAC roles",
-1
View File
@@ -90,7 +90,6 @@ pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
timeComparison,experimental,@grafana/dataviz-squad,false,false,true
tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true
kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true
cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false
alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false
jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
90 canvasPanelPanZoom preview @grafana/dataviz-squad false false true
91 timeComparison experimental @grafana/dataviz-squad false false true
92 tableSharedCrosshair experimental @grafana/dataviz-squad false false true
kubernetesFeatureToggles experimental @grafana/grafana-operator-experience-squad false false true
93 cloudRBACRoles preview @grafana/identity-access-team false true false
94 alertingQueryOptimization GA @grafana/alerting-squad false false false
95 jitterAlertRulesWithinGroups preview @grafana/alerting-squad false true false
+2 -1
View File
@@ -2044,7 +2044,8 @@
"metadata": {
"name": "kubernetesFeatureToggles",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-01-18T05:32:44Z"
"creationTimestamp": "2024-01-18T05:32:44Z",
"deletionTimestamp": "2026-01-07T12:02:51Z"
},
"spec": {
"description": "Use the kubernetes API for feature toggle management in the frontend",
@@ -3,7 +3,8 @@ import { render, screen, userEvent, waitFor } from 'test/test-utils';
import { byLabelText, byRole, byText } from 'testing-library-selector';
import { setPluginLinksHook } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import server from '@grafana/test-utils/server';
import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types/accessControl';
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
@@ -22,6 +23,7 @@ import {
mockPluginLinkExtension,
mockPromAlertingRule,
mockRulerGrafanaRecordingRule,
mockRulerGrafanaRule,
} from '../../mocks';
import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
import { grantPermissionsHelper } from '../../test/test-utils';
@@ -130,6 +132,8 @@ const dataSources = {
};
describe('RuleViewer', () => {
const api = mockAlertRuleApi(server);
beforeEach(() => {
setupDataSources(...Object.values(dataSources));
});
@@ -249,19 +253,22 @@ describe('RuleViewer', () => {
expect(screen.getAllByRole('row')).toHaveLength(7);
expect(screen.getAllByRole('row')[1]).toHaveTextContent(/6Provisioning2025-01-18 04:35:17/i);
expect(screen.getAllByRole('row')[1]).toHaveTextContent('+3-3Latest');
expect(screen.getAllByRole('row')[1]).toHaveTextContent('Updated by provisioning service');
expect(screen.getAllByRole('row')[1]).toHaveTextContent('+4-3Latest');
expect(screen.getAllByRole('row')[2]).toHaveTextContent(/5Alerting2025-01-17 04:35:17/i);
expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-5');
expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-6');
expect(screen.getAllByRole('row')[3]).toHaveTextContent(/4different user2025-01-16 04:35:17/i);
expect(screen.getAllByRole('row')[3]).toHaveTextContent('+5-5');
expect(screen.getAllByRole('row')[3]).toHaveTextContent('Changed alert title and thresholds');
expect(screen.getAllByRole('row')[3]).toHaveTextContent('+6-5');
expect(screen.getAllByRole('row')[4]).toHaveTextContent(/3user12025-01-15 04:35:17/i);
expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-9');
expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-10');
expect(screen.getAllByRole('row')[5]).toHaveTextContent(/2User ID foo2025-01-14 04:35:17/i);
expect(screen.getAllByRole('row')[5]).toHaveTextContent('+11-7');
expect(screen.getAllByRole('row')[5]).toHaveTextContent('Updated evaluation interval and routing');
expect(screen.getAllByRole('row')[5]).toHaveTextContent('+12-7');
expect(screen.getAllByRole('row')[6]).toHaveTextContent(/1Unknown 2025-01-13 04:35:17/i);
@@ -275,9 +282,10 @@ describe('RuleViewer', () => {
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled();
expect(screen.getByRole('cell', { name: /provisioning/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /alerting/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /Unknown/i })).toBeInTheDocument();
// Check for special updated_by values - use getAllByRole since some text appears in multiple columns
expect(screen.getAllByRole('cell', { name: /provisioning/i }).length).toBeGreaterThan(0);
expect(screen.getByRole('cell', { name: /^alerting$/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /^Unknown$/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /user id foo/i })).toBeInTheDocument();
});
@@ -321,6 +329,47 @@ describe('RuleViewer', () => {
await renderRuleViewer(rule, ruleIdentifier);
expect(screen.queryByText('Labels')).not.toBeInTheDocument();
});
it('shows Notes column when versions have messages', async () => {
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
expect(await screen.findByRole('columnheader', { name: /Notes/i })).toBeInTheDocument();
expect(screen.getAllByRole('row')).toHaveLength(7); // 1 header + 6 data rows
expect(screen.getByRole('cell', { name: /Updated by provisioning service/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /Changed alert title and thresholds/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /Updated evaluation interval and routing/i })).toBeInTheDocument();
});
it('does not show Notes column when no versions have messages', async () => {
const versionsWithoutMessages = [
mockRulerGrafanaRule(
{},
{
uid: grafanaRulerRule.grafana_alert.uid,
version: 2,
updated: '2025-01-14T09:35:17.000Z',
updated_by: { uid: 'foo', name: '' },
}
),
mockRulerGrafanaRule(
{},
{
uid: grafanaRulerRule.grafana_alert.uid,
version: 1,
updated: '2025-01-13T09:35:17.000Z',
updated_by: null,
}
),
];
api.getAlertRuleVersionHistory(grafanaRulerRule.grafana_alert.uid, versionsWithoutMessages);
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
await screen.findByRole('button', { name: /Compare versions/i });
expect(screen.getAllByRole('row')).toHaveLength(3); // 1 header + 2 data rows
expect(screen.queryByRole('columnheader', { name: /Notes/i })).not.toBeInTheDocument();
});
});
});
@@ -1,8 +1,9 @@
import { css } from '@emotion/css';
import { useMemo, useState } from 'react';
import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text } from '@grafana/ui';
import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text, useStyles2 } from '@grafana/ui';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { computeVersionDiff } from 'app/features/alerting/unified/utils/diff';
import { RuleIdentifier } from 'app/types/unified-alerting';
@@ -33,6 +34,7 @@ export function VersionHistoryTable({
onRestoreError,
canRestore,
}: VersionHistoryTableProps) {
const styles = useStyles2(getStyles);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [ruleToRestore, setRuleToRestore] = useState<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>();
const ruleToRestoreUid = ruleToRestore?.grafana_alert?.uid ?? '';
@@ -41,6 +43,8 @@ export function VersionHistoryTable({
[ruleToRestoreUid]
);
const hasAnyNotes = useMemo(() => ruleVersions.some((v) => v.grafana_alert.message), [ruleVersions]);
const showConfirmation = (ruleToRestore: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
setShowConfirmModal(true);
setRuleToRestore(ruleToRestore);
@@ -52,6 +56,15 @@ export function VersionHistoryTable({
const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown');
const notesColumn: Column<RulerGrafanaRuleDTO<GrafanaRuleDefinition>> = {
id: 'notes',
header: t('core.versionHistory.table.notes', 'Notes'),
cell: ({ row }) => {
const message = row.original.grafana_alert.message;
return message || null;
},
};
const columns: Array<Column<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>> = [
{
disableGrow: true,
@@ -91,9 +104,12 @@ export function VersionHistoryTable({
if (!value) {
return unknown;
}
return dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')';
return (
<span className={styles.nowrap}>{dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'}</span>
);
},
},
...(hasAnyNotes ? [notesColumn] : []),
{
id: 'diff',
disableGrow: true,
@@ -179,3 +195,9 @@ export function VersionHistoryTable({
</>
);
}
const getStyles = () => ({
nowrap: css({
whiteSpace: 'nowrap',
}),
});
@@ -154,6 +154,7 @@ export const rulerRuleVersionHistoryHandler = () => {
uid: 'service',
name: '',
};
draft.grafana_alert.message = 'Updated by provisioning service';
}),
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
draft.grafana_alert.version = 5;
@@ -171,6 +172,7 @@ export const rulerRuleVersionHistoryHandler = () => {
uid: 'different',
name: 'different user',
};
draft.grafana_alert.message = 'Changed alert title and thresholds';
}),
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
draft.grafana_alert.version = 3;
@@ -193,6 +195,7 @@ export const rulerRuleVersionHistoryHandler = () => {
uid: 'foo',
name: '',
};
draft.grafana_alert.message = 'Updated evaluation interval and routing';
}),
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
draft.grafana_alert.version = 1;
@@ -61,6 +61,16 @@ jest.mock('../state/query', () => ({
},
}));
jest.mock('app/core/context/GrafanaContext', () => ({
...jest.requireActual('app/core/context/GrafanaContext'),
useGrafana: () => ({
location: {
getSearchObject: jest.fn().mockReturnValue({}),
partial: jest.fn(),
},
}),
}));
describe('Logs', () => {
let originalHref = window.location.href;
+66 -20
View File
@@ -30,7 +30,6 @@ import {
serializeStateToUrlParam,
urlUtil,
LogLevel,
shallowCompare,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
@@ -47,6 +46,7 @@ import {
Themeable2,
withTheme2,
} from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import store from 'app/core/store';
import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks';
import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows';
@@ -74,6 +74,7 @@ import {
} from '../ContentOutline/ContentOutlineAnalyticEvents';
import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext';
import { getUrlStateFromPaneState } from '../hooks/useStateSync';
import { parseURL } from '../hooks/useStateSync/parseURL';
import { changePanelState } from '../state/explorePane';
import { changeQueries, runQueries } from '../state/query';
@@ -82,6 +83,7 @@ import { LogsMetaRow } from './LogsMetaRow';
import LogsNavigation from './LogsNavigation';
import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap';
import { LogsVolumePanelList } from './LogsVolumePanelList';
import { migrateLegacyColumns } from './utils/columnMigration';
import { SETTING_KEY_ROOT, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs';
import { getExploreBaseUrl } from './utils/url';
@@ -201,8 +203,9 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
panelState?.logs?.sortOrder ?? store.get(SETTINGS_KEYS.logsSortOrder) ?? LogsSortOrder.Descending
);
const [isFlipping, setIsFlipping] = useState<boolean>(false);
const [displayedFields, setDisplayedFields] = useState<string[]>(panelState?.logs?.displayedFields ?? []);
const [defaultDisplayedFields, setDefaultDisplayedFields] = useState<string[]>([]);
// Use Redux state as single source of truth
const displayedFields = useMemo(() => panelState?.logs?.displayedFields ?? [], [panelState?.logs?.displayedFields]);
const [contextOpen, setContextOpen] = useState<boolean>(false);
const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined);
const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState<PopoverContent>(PINNED_LOGS_MESSAGE);
@@ -212,6 +215,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const dispatch = useDispatch();
const previousLoading = usePrevious(loading);
const { location } = useGrafana();
const logsVolumeEventBus = eventBus.newScopedBus('logsvolume', { onlyLocal: false });
const { register, unregister, outlineItems, updateItem } = useContentOutlineContext() ?? {};
@@ -322,7 +326,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
dispatch(
changePanelState(exploreId, 'logs', {
...state.panelsState.logs,
columns: logsPanelState.columns ?? panelState?.logs?.columns,
visualisationType: logsPanelState.visualisationType ?? visualisationType,
labelFieldName: logsPanelState.labelFieldName,
refId: logsPanelState.refId ?? panelState?.logs?.refId,
@@ -336,7 +339,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
[
dispatch,
exploreId,
panelState?.logs?.columns,
panelState?.logs?.displayedFields,
panelState?.logs?.refId,
panelState?.logs?.tableSortBy,
@@ -345,14 +347,38 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
]
);
// Migration: Convert legacy 'columns' parameter from URL to 'displayedFields'
useEffect(() => {
if (!shallowCompare(displayedFields, panelState?.logs?.displayedFields ?? [])) {
updatePanelState({
...panelState?.logs,
displayedFields,
});
// Parse URL to check for legacy columns
const urlParams = location.getSearchObject();
const [urlState] = parseURL(urlParams);
// Find the pane - exploreId might not match the URL pane key directly
const urlPane = urlState.panes[exploreId] ?? Object.values(urlState.panes)[0];
if (!urlPane?.panelsState?.logs) {
return;
}
}, [displayedFields, panelState?.logs, updatePanelState]);
// Get current displayedFields to use as defaults for merge
const currentDisplayedFields = displayedFields;
// Use migration utility to parse and transform legacy columns
const mergedFields = migrateLegacyColumns(urlPane.panelsState.logs, currentDisplayedFields, visualisationType);
if (!mergedFields) {
return;
}
// Update displayedFields in Redux state - URL sync will handle URL update
dispatch(
changePanelState(exploreId, 'logs', {
...panelState?.logs,
columns: undefined, // Remove columns from URL
displayedFields: mergedFields,
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only on mount
// actions
const onLogRowHover = useCallback(
@@ -541,30 +567,48 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const showField = useCallback(
(key: string) => {
const index = displayedFields.indexOf(key);
const currentFields = panelState?.logs?.displayedFields ?? [];
const index = currentFields.indexOf(key);
if (index === -1) {
const updatedDisplayedFields = displayedFields.concat(key);
setDisplayedFields(updatedDisplayedFields);
const updatedDisplayedFields = currentFields.concat(key);
updatePanelState({
displayedFields: updatedDisplayedFields,
});
}
},
[displayedFields]
[panelState?.logs?.displayedFields, updatePanelState]
);
const hideField = useCallback(
(key: string) => {
const index = displayedFields.indexOf(key);
const currentFields = panelState?.logs?.displayedFields ?? [];
const index = currentFields.indexOf(key);
if (index > -1) {
const updatedDisplayedFields = displayedFields.filter((k) => key !== k);
setDisplayedFields(updatedDisplayedFields);
const updatedDisplayedFields = currentFields.filter((k) => key !== k);
updatePanelState({
displayedFields: updatedDisplayedFields,
});
}
},
[displayedFields]
[panelState?.logs?.displayedFields, updatePanelState]
);
const clearDisplayedFields = useCallback(() => {
setDisplayedFields([]);
}, []);
updatePanelState({
displayedFields: defaultDisplayedFields,
});
}, [defaultDisplayedFields, updatePanelState]);
// Wrapper function for setDisplayedFields prop - updates Redux directly
const setDisplayedFields = useCallback(
(fields: string[]) => {
updatePanelState({
displayedFields: fields,
});
},
[updatePanelState]
);
const onCloseCallbackRef = useRef<() => void>(() => {});
@@ -1003,6 +1047,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
updatePanelState={updatePanelState}
datasourceType={props.datasourceType}
displayedFields={displayedFields}
defaultDisplayedFields={defaultDisplayedFields}
exploreId={props.exploreId}
absoluteRange={props.absoluteRange}
logRows={props.logRows}
@@ -1042,6 +1087,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
displayedFields={displayedFields}
defaultDisplayedFields={defaultDisplayedFields}
onClickShowField={showField}
onClickHideField={hideField}
app={CoreApp.Explore}
@@ -48,7 +48,7 @@ describe('LogsMetaRow', () => {
});
it('renders the show original line button', () => {
setup({ displayedFields: ['test'] });
setup({ displayedFields: ['test'], defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'] });
expect(
screen.getByRole('button', {
name: 'Show original line',
@@ -66,13 +66,20 @@ describe('LogsMetaRow', () => {
});
it('renders the displayed fields', async () => {
setup({ displayedFields: ['testField1234'] });
setup({
displayedFields: ['testField1234'],
defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'],
});
expect(await screen.findByText('testField1234')).toBeInTheDocument();
});
it('renders a button to clear displayedfields', () => {
const clearSpy = jest.fn();
setup({ displayedFields: ['testField1234'], clearDisplayedFields: clearSpy });
setup({
displayedFields: ['testField1234'],
defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'],
clearDisplayedFields: clearSpy,
});
fireEvent(
screen.getByRole('button', {
name: 'Show original line',
@@ -1,16 +1,7 @@
import { css } from '@emotion/css';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import {
LogsDedupStrategy,
LogsMetaItem,
LogsMetaKind,
LogRowModel,
CoreApp,
Labels,
store,
shallowCompare,
} from '@grafana/data';
import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels, store } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
@@ -58,6 +49,14 @@ export const LogsMetaRow = memo(
}: Props) => {
const style = useStyles2(getStyles);
// Filter out default fields from displayedFields to show only user-added fields
const nonDefaultFields = useMemo(() => {
if (!displayedFields?.length || !defaultDisplayedFields?.length) {
return [];
}
return displayedFields.filter((field) => !defaultDisplayedFields.includes(field));
}, [displayedFields, defaultDisplayedFields]);
const logsMetaItem: Array<LogsMetaItem | MetaItemProps> = [...meta];
// Add deduplication info
@@ -69,16 +68,12 @@ export const LogsMetaRow = memo(
});
}
// Add detected fields info
if (
visualisationType === 'logs' &&
displayedFields?.length > 0 &&
shallowCompare(displayedFields, defaultDisplayedFields) === false
) {
// Add detected fields info - only show when user has added fields beyond defaults
if (visualisationType === 'logs' && nonDefaultFields.length > 0) {
logsMetaItem.push(
{
label: t('explore.logs-meta-row.label.showing-only-selected-fields', 'Showing only selected fields'),
value: <LogLabelsList labels={displayedFields} />,
value: <LogLabelsList labels={nonDefaultFields} />,
},
{
label: '',
+13 -6
View File
@@ -33,7 +33,9 @@ import {
useStyles2,
} from '@grafana/ui';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal';
import { LogsFrame } from 'app/features/logs/logsFrame';
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 { getFieldLinksForExplore } from '../utils/links';
@@ -154,9 +156,9 @@ export function LogsTable(props: Props) {
},
});
// `getLinks` and `applyFieldOverrides` are taken from TableContainer.tsx
for (const [index, field] of frameWithOverrides.fields.entries()) {
for (const [fieldIdx, field] of frameWithOverrides.fields.entries()) {
// Hide ID field from visualization (it's only needed for row matching)
if (logsFrame?.idField && (field.name === logsFrame.idField.name || field.name === 'id')) {
if (logsFrame?.idField && (field.name === logsFrame.idField.name || field.name === DATAPLANE_ID_NAME)) {
field.config = {
...field.config,
custom: {
@@ -180,7 +182,7 @@ export function LogsTable(props: Props) {
};
// For the first field (time), wrap the cell to include action buttons
const isFirstField = index === 0;
const isFirstField = fieldIdx === 0;
field.config = {
...field.config,
@@ -202,7 +204,6 @@ export function LogsTable(props: Props) {
panelState={props.panelState}
absoluteRange={props.absoluteRange}
logRows={props.logRows}
rowIndex={cellProps.rowIndex}
/>
<span className={styles.firstColumnCell}>
{cellProps.field.display?.(cellProps.value).text ?? String(cellProps.value)}
@@ -397,9 +398,10 @@ export function getLogsExtractFields(dataFrame: DataFrame) {
function buildLabelFilters(columnsWithMeta: Record<string, FieldNameMeta>) {
// Create object of label filters to include columns selected by the user
// Exclude OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME from table view
let labelFilters: Record<string, number> = {};
Object.keys(columnsWithMeta)
.filter((key) => columnsWithMeta[key].active)
.filter((key) => columnsWithMeta[key].active && key !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)
.forEach((key) => {
const index = columnsWithMeta[key].index;
// Index should always be defined for any active column
@@ -434,6 +436,11 @@ function getInitialFieldWidth(field: Field): number | undefined {
if (field.type === FieldType.time) {
return 230;
}
// Set constrained width for detected_level
if (field.name === TABLE_DETECTED_LEVEL_FIELD_NAME) {
return 190;
}
// All other fields (including body field) will auto-expand
return undefined;
}
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useCallback, useState } from 'react';
import { useCallback, useState, memo } from 'react';
import {
AbsoluteTimeRange,
@@ -13,7 +13,7 @@ import { t } from '@grafana/i18n';
import { ClipboardButton, CustomCellRendererProps, IconButton, Modal, useTheme2 } from '@grafana/ui';
import { getLogsPermalinkRange } from 'app/core/utils/shortLinks';
import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync';
import { LogsFrame } from 'app/features/logs/logsFrame';
import { LogsFrame, DATAPLANE_ID_NAME } from 'app/features/logs/logsFrame';
import { getState } from 'app/store/store';
import { getExploreBaseUrl } from './utils/url';
@@ -28,26 +28,26 @@ interface Props extends CustomCellRendererProps {
index?: number;
}
export function LogsTableActionButtons(props: Props) {
export const LogsTableActionButtons = memo((props: Props) => {
const { exploreId, absoluteRange, logRows, rowIndex, panelState, displayedFields, logsFrame, frame } = props;
const theme = useTheme2();
const [isInspecting, setIsInspecting] = useState(false);
// Get logId from the table frame (frame), not the original logsFrame, because
// the table frame is sorted/transformed and rowIndex refers to the table frame
const idFieldName = logsFrame?.idField?.name ?? 'id';
const idField = frame.fields.find((field) => field.name === idFieldName || field.name === 'id');
const idFieldName = logsFrame?.idField?.name ?? DATAPLANE_ID_NAME;
const idField = frame.fields.find((field) => field.name === idFieldName || field.name === DATAPLANE_ID_NAME);
const logId = idField?.values[rowIndex];
const getLineValue = () => {
const bodyFieldName = logsFrame?.bodyField?.name;
const bodyField = bodyFieldName
? frame.fields.find((field) => field.name === bodyFieldName)
: frame.fields.find((field) => field.type === 'string');
return bodyField?.values[rowIndex];
const logRowById = logRows?.find((row) => row.rowId === logId);
return logRowById?.raw ?? '';
};
const lineValue = getLineValue();
// Check if line value is available
const isLineValueAvailable = lineValue !== undefined && lineValue !== null && lineValue !== '';
const styles = getStyles(theme);
// Generate link to the log line
@@ -99,39 +99,39 @@ export function LogsTableActionButtons(props: Props) {
}, [absoluteRange, displayedFields, exploreId, logId, logRows, rowIndex, panelState]);
const handleViewClick = () => {
setIsInspecting(true);
if (isLineValueAvailable) {
setIsInspecting(true);
}
};
return (
<>
<div className={styles.iconWrapper}>
<div className={styles.inspect}>
<IconButton
className={styles.inspectButton}
tooltip={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
variant="secondary"
aria-label={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
tooltipPlacement="top"
size="md"
name="eye"
onClick={handleViewClick}
tabIndex={0}
/>
</div>
<div className={styles.inspect}>
<ClipboardButton
className={styles.clipboardButton}
icon="share-alt"
variant="secondary"
fill="text"
size="md"
tooltip={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
tooltipPlacement="top"
tabIndex={0}
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
getText={getText}
/>
</div>
<IconButton
className={styles.icon}
tooltip={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
variant="secondary"
aria-label={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
tooltipPlacement="top"
size="md"
name="eye"
onClick={handleViewClick}
tabIndex={0}
disabled={!isLineValueAvailable}
/>
<ClipboardButton
className={styles.icon}
icon="share-alt"
variant="secondary"
fill="text"
size="md"
tooltip={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
tooltipPlacement="top"
tabIndex={0}
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
getText={getText}
disabled={!isLineValueAvailable}
/>
</div>
{isInspecting && (
<Modal
@@ -139,9 +139,9 @@ export function LogsTableActionButtons(props: Props) {
isOpen={true}
title={t('explore.logs-table.action-buttons.inspect-value', 'Inspect value')}
>
<pre>{lineValue}</pre>
<pre>{getLineValue()}</pre>
<Modal.ButtonRow>
<ClipboardButton icon="copy" getText={() => lineValue}>
<ClipboardButton icon="copy" getText={() => getLineValue()}>
{t('explore.logs-table.action-buttons.copy-to-clipboard', 'Copy to Clipboard')}
</ClipboardButton>
</Modal.ButtonRow>
@@ -149,15 +149,11 @@ export function LogsTableActionButtons(props: Props) {
)}
</>
);
}
});
export const getStyles = (theme: GrafanaTheme2) => ({
clipboardButton: css({
height: '100%',
lineHeight: '1',
padding: 0,
width: '20px',
}),
LogsTableActionButtons.displayName = 'LogsTableActionButtons';
const getStyles = (theme: GrafanaTheme2) => ({
iconWrapper: css({
background: theme.colors.background.secondary,
boxShadow: theme.shadows.z2,
@@ -166,25 +162,50 @@ export const getStyles = (theme: GrafanaTheme2) => ({
height: '35px',
left: 0,
top: 0,
padding: `0 ${theme.spacing(0.5)}`,
padding: 0,
position: 'absolute',
zIndex: 1,
alignItems: 'center',
// Fix switching icon direction when cell is numeric (rtl)
direction: 'ltr',
}),
inspect: css({
'& button svg': {
marginRight: 'auto',
icon: css({
gap: 0,
margin: 0,
padding: 0,
borderRadius: theme.shape.radius.default,
width: '28px',
height: '32px',
display: 'inline-flex',
justifyContent: 'center',
'&:before': {
content: '""',
position: 'absolute',
width: 24,
height: 24,
top: 0,
bottom: 0,
left: 0,
right: 0,
margin: 'auto',
borderRadius: theme.shape.radius.default,
backgroundColor: theme.colors.background.primary,
zIndex: -1,
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transitionDuration: '0.2s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionProperty: 'opacity',
},
},
'&:hover': {
color: theme.colors.text.link,
cursor: 'pointer',
background: 'none',
'&:before': {
opacity: 1,
},
},
padding: '5px 3px',
}),
inspectButton: css({
borderRadius: theme.shape.radius.default,
display: 'inline-flex',
margin: 0,
overflow: 'hidden',
verticalAlign: 'middle',
}),
});
@@ -67,7 +67,7 @@ describe('LogsTableWrap', () => {
setup({
panelState: {
visualisationType: 'table',
columns: undefined,
displayedFields: undefined,
},
updatePanelState: updatePanelState,
});
@@ -84,7 +84,7 @@ describe('LogsTableWrap', () => {
await waitFor(() => {
expect(updatePanelState).toBeCalledWith({
visualisationType: 'table',
columns: { 0: 'app', 1: 'Line', 2: 'Time' },
displayedFields: ['app', '___LOG_LINE_BODY___', 'Time'],
labelFieldName: 'labels',
});
});
@@ -97,7 +97,7 @@ describe('LogsTableWrap', () => {
await waitFor(() => {
expect(updatePanelState).toBeCalledWith({
visualisationType: 'table',
columns: { 0: 'Line', 1: 'Time' },
displayedFields: ['___LOG_LINE_BODY___', 'Time'],
labelFieldName: 'labels',
});
});
@@ -109,7 +109,7 @@ describe('LogsTableWrap', () => {
setup({
panelState: {
visualisationType: 'table',
columns: undefined,
displayedFields: undefined,
},
updatePanelState: updatePanelState,
});
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { Resizable, ResizeCallback } from 're-resizable';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
DataFrame,
@@ -14,15 +14,23 @@ import {
store,
TimeRange,
AbsoluteTimeRange,
shallowCompare,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { getDragStyles, InlineField, Select, useStyles2 } from '@grafana/ui';
import {
TABLE_TIME_FIELD_NAME,
TABLE_LINE_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
} from 'app/features/logs/components/LogDetailsBody';
import {
getFieldSelectorWidth,
LogsTableFieldSelector,
MIN_WIDTH,
} from 'app/features/logs/components/fieldSelector/FieldSelector';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from 'app/features/logs/components/otel/formats';
import { reportInteractionOnce } from 'app/features/logs/components/panel/analytics';
import { parseLogsFrame } from '../../logs/logsFrame';
@@ -44,6 +52,7 @@ interface Props {
datasourceType?: string;
exploreId?: string;
displayedFields?: string[];
defaultDisplayedFields?: string[];
absoluteRange?: AbsoluteTimeRange;
logRows?: LogRowModel[];
}
@@ -69,10 +78,15 @@ type FieldName = string;
export type FieldNameMetaStore = Record<FieldName, FieldNameMeta>;
export function LogsTableWrap(props: Props) {
const { logsFrames, updatePanelState, panelState } = props;
const propsColumns = panelState?.columns;
const { logsFrames, updatePanelState, panelState, defaultDisplayedFields } = props;
const propsColumns = panelState?.displayedFields;
// Save the normalized cardinality of each label
const [columnsWithMeta, setColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined);
// Use ref to access columnsWithMeta in useEffect without causing infinite loops
const columnsWithMetaRef = useRef(columnsWithMeta);
useEffect(() => {
columnsWithMetaRef.current = columnsWithMeta;
}, [columnsWithMeta]);
const dragStyles = useStyles2(getDragStyles);
// Filtered copy of columnsWithMeta that only includes matching results
@@ -86,34 +100,41 @@ export function LogsTableWrap(props: Props) {
logsFrames.find((f) => f.refId === panelStateRefId) ?? logsFrames[0]
);
const logsFrame = useMemo(() => parseLogsFrame(currentDataFrame), [currentDataFrame]);
const getColumnsFromProps = useCallback(
(fieldNames: FieldNameMetaStore) => {
const previouslySelected = props.panelState?.columns;
const previouslySelected = props.panelState?.displayedFields;
if (previouslySelected) {
Object.values(previouslySelected).forEach((key, index) => {
if (fieldNames[key]) {
fieldNames[key].active = true;
fieldNames[key].index = index;
// Map LOG_LINE_BODY_FIELD_NAME to actual body field name
const mappedKey =
key === LOG_LINE_BODY_FIELD_NAME ? (logsFrame?.bodyField?.name ?? TABLE_LINE_FIELD_NAME) : key;
if (fieldNames[mappedKey]) {
fieldNames[mappedKey].active = true;
fieldNames[mappedKey].index = index;
}
});
}
return fieldNames;
},
[props.panelState?.columns]
[props.panelState?.displayedFields, logsFrame?.bodyField?.name]
);
const logsFrame = useMemo(() => parseLogsFrame(currentDataFrame), [currentDataFrame]);
useEffect(() => {
if (logsFrame?.timeField.name && logsFrame?.bodyField.name && !propsColumns) {
const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' };
// Use defaultDisplayedFields if available, otherwise fall back to basic defaults
const columns = defaultDisplayedFields?.length
? defaultDisplayedFields
: [logsFrame?.timeField.name, logsFrame?.bodyField.name];
updatePanelState({
columns: Object.values(defaultColumns),
displayedFields: columns,
visualisationType: 'table',
labelFieldName: logsFrame?.getLabelFieldName() ?? undefined,
});
}
}, [logsFrame, propsColumns, updatePanelState]);
}, [logsFrame, propsColumns, updatePanelState, defaultDisplayedFields]);
/**
* When logs frame updates (e.g. query|range changes), we need to set the selected frame to state
@@ -187,6 +208,7 @@ export function LogsTableWrap(props: Props) {
// If we have labels and log lines
if (labels?.length && numberOfLogLines) {
const displayedFields = props.panelState?.displayedFields ?? [];
// Iterate through all of Labels
labels.forEach((labels: Labels) => {
const labelsArray = Object.keys(labels);
@@ -196,11 +218,19 @@ export function LogsTableWrap(props: Props) {
if (labelCardinality.has(label)) {
const value = labelCardinality.get(label);
if (value) {
if (value?.active) {
// Check displayedFields first, then fall back to current value
const isActiveInDisplayedFields = displayedFields.includes(label);
const currentMeta = columnsWithMetaRef.current?.[label];
const shouldBeActive = isActiveInDisplayedFields || currentMeta?.active || value.active;
const index = isActiveInDisplayedFields
? displayedFields.indexOf(label)
: (currentMeta?.index ?? value.index);
if (shouldBeActive && index !== undefined) {
labelCardinality.set(label, {
percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1,
active: true,
index: value.index,
index: index,
});
} else {
labelCardinality.set(label, {
@@ -212,7 +242,25 @@ export function LogsTableWrap(props: Props) {
}
// Otherwise add it
} else {
labelCardinality.set(label, { percentOfLinesWithLabel: 1, active: false, index: undefined });
// Check if this label is in displayedFields
const isActiveInDisplayedFields = displayedFields.includes(label);
const currentMeta = columnsWithMetaRef.current?.[label];
const shouldBeActive = isActiveInDisplayedFields || currentMeta?.active || false;
const index = isActiveInDisplayedFields ? displayedFields.indexOf(label) : currentMeta?.index;
if (shouldBeActive && index !== undefined) {
labelCardinality.set(label, {
percentOfLinesWithLabel: 1,
active: true,
index: index,
});
} else {
labelCardinality.set(label, {
percentOfLinesWithLabel: 1,
active: false,
index: undefined,
});
}
}
});
});
@@ -230,9 +278,14 @@ export function LogsTableWrap(props: Props) {
}
// Normalize the other fields
const displayedFields = props.panelState?.displayedFields ?? [];
otherFields.forEach((field) => {
const isActive = pendingLabelState[field.name]?.active;
const index = pendingLabelState[field.name]?.index;
// Check displayedFields first, then fall back to current columnsWithMeta
const isActiveInDisplayedFields = displayedFields.includes(field.name);
const currentMeta = columnsWithMetaRef.current?.[field.name];
const isActive = isActiveInDisplayedFields || currentMeta?.active || false;
const index = isActiveInDisplayedFields ? displayedFields.indexOf(field.name) : currentMeta?.index;
if (isActive && index !== undefined) {
pendingLabelState[field.name] = {
percentOfLinesWithLabel: normalize(
@@ -274,10 +327,13 @@ export function LogsTableWrap(props: Props) {
pendingLabelState[logsFrame.timeField.name].type = 'TIME_FIELD';
}
setColumnsWithMeta(pendingLabelState);
// Only update if the state actually changed to prevent infinite loops
if (!columnsWithMetaRef.current || !shallowCompare(columnsWithMetaRef.current, pendingLabelState)) {
setColumnsWithMeta(pendingLabelState);
}
// The panel state is updated when the user interacts with the multi-select sidebar
}, [currentDataFrame, getColumnsFromProps]);
}, [currentDataFrame, getColumnsFromProps, props.panelState?.displayedFields]);
const [sidebarWidth, setSidebarWidth] = useState(getFieldSelectorWidth(SETTING_KEY_ROOT));
const tableWidth = props.width - sidebarWidth;
@@ -323,17 +379,33 @@ export function LogsTableWrap(props: Props) {
const clearSelection = () => {
const pendingLabelState = { ...columnsWithMeta };
Object.keys(pendingLabelState).forEach((key) => {
const isDefaultField = !!pendingLabelState[key].type;
// after reset the only active fields are the special time and body fields
pendingLabelState[key].active = isDefaultField ? true : false;
// reset the index
if (pendingLabelState[key].type === 'TIME_FIELD') {
pendingLabelState[key].index = 0;
const field = pendingLabelState[key];
const isTimeField = field.type === 'TIME_FIELD' || key === TABLE_TIME_FIELD_NAME;
const isBodyField = field.type === 'BODY_FIELD' || key === TABLE_LINE_FIELD_NAME;
const isDetectedLevel = key === TABLE_DETECTED_LEVEL_FIELD_NAME;
// After reset, only active fields are Time, detected_level, and Line
if (isTimeField || isBodyField || isDetectedLevel) {
pendingLabelState[key].active = true;
// Set indices: Time at 0, detected_level at 1, Line at 2
if (isTimeField) {
pendingLabelState[key].index = 0;
} else if (isDetectedLevel) {
pendingLabelState[key].index = 1;
} else if (isBodyField) {
pendingLabelState[key].index = 2;
}
} else {
pendingLabelState[key].index = pendingLabelState[key].type === 'BODY_FIELD' ? 1 : undefined;
pendingLabelState[key].active = false;
pendingLabelState[key].index = undefined;
}
});
setColumnsWithMeta(pendingLabelState);
// Reset displayedFields to defaults
updatePanelState({
displayedFields: defaultDisplayedFields?.length ? defaultDisplayedFields : [],
});
};
const reorderColumn = (newColumns: string[]) => {
@@ -364,17 +436,29 @@ export function LogsTableWrap(props: Props) {
return 0;
});
const newColumns: Record<number, string> = Object.assign(
{},
// Get the keys of the object as an array
newColumnsArray
);
// Map body field name to LOG_LINE_BODY_FIELD_NAME
const bodyFieldName = logsFrame?.bodyField?.name ?? TABLE_LINE_FIELD_NAME;
const bodyFieldIndex = newColumnsArray.indexOf(bodyFieldName);
if (bodyFieldIndex !== -1) {
// Replace body field name with LOG_LINE_BODY_FIELD_NAME
newColumnsArray[bodyFieldIndex] = LOG_LINE_BODY_FIELD_NAME;
}
const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' };
// Preserve ___OTEL_LOG_ATTRIBUTES___ from displayedFields if it exists
const currentDisplayedFields = props.panelState?.displayedFields ?? [];
const otelAttributesIndex = currentDisplayedFields.indexOf(OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME);
if (otelAttributesIndex !== -1 && !newColumnsArray.includes(OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)) {
// Insert at original position if it was in displayedFields
newColumnsArray.splice(otelAttributesIndex, 0, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME);
}
const defaultColumns: string[] = [logsFrame?.timeField.name, logsFrame?.bodyField.name].filter(
(name): name is string => name !== undefined
);
const newPanelState: ExploreLogsPanelState = {
...props.panelState,
// URL format requires our array of values be an object, so we convert it using object.assign
columns: Object.keys(newColumns).length ? newColumns : defaultColumns,
displayedFields: newColumnsArray.length ? newColumnsArray : defaultColumns,
refId: currentDataFrame.refId,
visualisationType: 'table',
labelFieldName: logsFrame?.getLabelFieldName() ?? undefined,
@@ -447,7 +531,6 @@ export function LogsTableWrap(props: Props) {
setFilteredColumnsWithMeta(pendingFilteredLabelState);
}
updateExploreState(pendingLabelState);
};
@@ -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']);
});
});
});
});
@@ -0,0 +1,183 @@
import {
LOG_LINE_BODY_FIELD_NAME,
TABLE_LINE_FIELD_NAME,
TABLE_TIME_FIELD_NAME,
} from 'app/features/logs/components/LogDetailsBody';
/**
* Migration utility for converting legacy 'columns' URL parameter to 'displayedFields'.
*/
/**
* Parses legacy columns value from URL.
* Handles both array format and object format (e.g., {0: 'Time', 1: 'Line'}).
*
* @param columnsValue - The raw columns value from URL state
* @returns Array of column names, or null if invalid/empty
*/
export function parseLegacyColumns(columnsValue: unknown): string[] | null {
if (columnsValue === null || columnsValue === undefined) {
return null;
}
// Handle array format
if (Array.isArray(columnsValue)) {
if (columnsValue.length === 0) {
return null;
}
// Validate all elements are strings
if (columnsValue.every((v) => typeof v === 'string')) {
return columnsValue;
}
return null;
}
// Handle object format (e.g., {0: 'Time', 1: 'Line'})
if (typeof columnsValue === 'object') {
const values = Object.values(columnsValue);
if (values.length === 0) {
return null;
}
// Validate all values are strings and filter to string array
if (values.every((v): v is string => typeof v === 'string')) {
return values;
}
}
return null;
}
/**
* Maps legacy field names to their new equivalents.
* Maps: 'Line' -> LOG_LINE_BODY_FIELD_NAME, 'timestamp' -> 'Time', 'body' -> LOG_LINE_BODY_FIELD_NAME
*
* @param columns - Array of column names
* @returns Array with mapped column names
*/
export function mapLegacyFieldNames(columns: string[]): string[] {
return columns.map((column) => {
// Map 'Line' to LOG_LINE_BODY_FIELD_NAME
if (column === TABLE_LINE_FIELD_NAME) {
return LOG_LINE_BODY_FIELD_NAME;
}
// Map 'timestamp' to TABLE_TIME_FIELD_NAME ('Time')
if (column === 'timestamp') {
return TABLE_TIME_FIELD_NAME;
}
// Map 'body' to LOG_LINE_BODY_FIELD_NAME
if (column === 'body') {
return LOG_LINE_BODY_FIELD_NAME;
}
return column;
});
}
/**
* Merges migrated columns with default displayed fields.
* Default fields come first, then migrated columns (avoiding duplicates).
*
* @param migratedColumns - Columns from the legacy format (already mapped)
* @param defaultFields - Default fields to display
* @returns Merged array with defaults first, no duplicates
*/
export function mergeWithDefaults(migratedColumns: string[], defaultFields: string[]): string[] {
const mergedFields = [...defaultFields];
migratedColumns.forEach((column) => {
if (!mergedFields.includes(column)) {
mergedFields.push(column);
}
});
return mergedFields;
}
/**
* Checks if a logs state object contains legacy columns that need migration.
* Acts as a type guard to narrow the type to an object with columns property.
*
* @param logsState - The logs panel state from URL
* @returns True if legacy columns exist
*/
export function hasLegacyColumns(logsState: unknown): logsState is object & { columns: unknown } {
if (!logsState || typeof logsState !== 'object') {
return false;
}
return 'columns' in logsState;
}
/**
* Extracts the columns value from logs state using safe property access.
*
* @param logsState - The logs panel state from URL
* @returns The columns value, or undefined if not present
*/
export function extractColumnsValue(logsState: object): unknown {
const descriptor = Object.getOwnPropertyDescriptor(logsState, 'columns');
return descriptor?.value;
}
/**
* Extracts the displayedFields value from logs state using safe property access.
*
* @param logsState - The logs panel state from URL
* @returns The displayedFields value, or undefined if not present
*/
export function extractDisplayedFields(logsState: object): unknown {
const descriptor = Object.getOwnPropertyDescriptor(logsState, 'displayedFields');
return descriptor?.value;
}
/**
* Main migration function - orchestrates the full migration process.
* Returns the migrated and merged fields, or null if no migration is needed.
*
* For table visualization: merges defaults with legacy 'columns' from URL
* For logs visualization: merges defaults with 'displayedFields' from URL
*
* @param logsState - The logs panel state from URL
* @param defaultDisplayedFields - Default fields to merge with
* @param visualisationType - The current visualization type ('table' or 'logs')
* @returns Merged displayed fields array, or null if no migration needed
*/
export function migrateLegacyColumns(
logsState: unknown,
defaultDisplayedFields: string[],
visualisationType?: string
): string[] | null {
// Ensure logsState is an object
// Only run this migration if legacy columns are present
if (!logsState || typeof logsState !== 'object' || !hasLegacyColumns(logsState)) {
return null;
}
// For table visualization: only use columns from URL and map the old field names to the new ones
if (visualisationType === 'table') {
const columnsValue = extractColumnsValue(logsState);
const parsedColumns = parseLegacyColumns(columnsValue);
if (!parsedColumns) {
return null;
}
// Map legacy field names to new names
const mappedColumns = mapLegacyFieldNames(parsedColumns);
return mappedColumns;
}
// For logs visualization only use displayedFields from URL
if (visualisationType === 'logs') {
const displayedFieldsValue = extractDisplayedFields(logsState);
// displayedFields should already be an array of strings
if (!Array.isArray(displayedFieldsValue) || displayedFieldsValue.length === 0) {
return null;
}
return displayedFieldsValue;
}
// No visualisationType specified or unknown type - return null
return null;
}
@@ -2,6 +2,8 @@ import { DataFrame, ExplorePanelsState } from '@grafana/data';
import { t } from '@grafana/i18n';
import { DataQuery, DataSourceRef, Panel } from '@grafana/schema';
import { DataTransformerConfig } from '@grafana/schema/dist/esm/raw/dashboard/x/dashboard_types.gen';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_TIME_FIELD_NAME } from 'app/features/logs/components/LogDetailsBody';
import { parseLogsFrame } from 'app/features/logs/logsFrame';
import { ExplorePanelData } from 'app/types/explore';
interface ExploreToDashboardPanelOptions {
@@ -24,7 +26,7 @@ function getLogsTableTransformations(
options: ExploreToDashboardPanelOptions
): DataTransformerConfig[] {
let transformations: DataTransformerConfig[] = [];
if (panelType === 'table' && options.panelState?.logs?.columns) {
if (panelType === 'table' && options.panelState?.logs?.displayedFields) {
// If we have a labels column, we need to extract the fields from it
if (options.panelState.logs?.labelFieldName) {
transformations.push({
@@ -35,18 +37,37 @@ function getLogsTableTransformations(
});
}
// Map constant field names to actual field names from the data frame
// Find the first logs frame to get the actual field names
const logsFrame = options.queryResponse.logsFrames.find((frame) => frame.refId === options.panelState?.logs?.refId);
const parsedLogsFrame = logsFrame ? parseLogsFrame(logsFrame) : null;
// Map displayedFields from constant names to actual field names
const mappedDisplayedFields = options.panelState.logs.displayedFields.map((fieldName) => {
// Map LOG_LINE_BODY_FIELD_NAME to actual body field name
if (fieldName === LOG_LINE_BODY_FIELD_NAME) {
return parsedLogsFrame?.bodyField?.name ?? fieldName;
}
// Map TABLE_TIME_FIELD_NAME to actual time field name
if (fieldName === TABLE_TIME_FIELD_NAME) {
return parsedLogsFrame?.timeField?.name ?? fieldName;
}
// Return as-is for other fields (including extracted labels)
return fieldName;
});
// Show the columns that the user selected in explore
transformations.push({
id: 'organize',
options: {
indexByName: Object.values(options.panelState.logs.columns).reduce(
indexByName: mappedDisplayedFields.reduce(
(acc: Record<string, number>, value: string, idx) => ({
...acc,
[value]: idx,
}),
{}
),
includeByName: Object.values(options.panelState.logs.columns).reduce(
includeByName: mappedDisplayedFields.reduce(
(acc: Record<string, boolean>, value: string) => ({
...acc,
[value]: true,
@@ -43,6 +43,7 @@ export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
width?: number;
logsTableFrames?: DataFrame[];
displayedFields?: string[];
defaultDisplayedFields?: string[];
exploreId?: string;
absoluteRange?: AbsoluteTimeRange;
logRows?: LogRowModel[];
@@ -26,6 +26,7 @@ export const ControlledLogsTable = ({
logsTableFrames,
visualisationType,
displayedFields,
defaultDisplayedFields,
exploreId,
absoluteRange,
logRows,
@@ -63,6 +64,7 @@ export const ControlledLogsTable = ({
updatePanelState={updatePanelState}
datasourceType={datasourceType}
displayedFields={displayedFields}
defaultDisplayedFields={defaultDisplayedFields}
exploreId={exploreId}
absoluteRange={absoluteRange}
logRows={logRows}
@@ -31,6 +31,12 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => {
export const LOG_LINE_BODY_FIELD_NAME = '___LOG_LINE_BODY___';
// Table view field constants
export const TABLE_TIME_FIELD_NAME = 'Time';
export const TABLE_LINE_FIELD_NAME = 'Line';
export const TABLE_DETECTED_LEVEL_FIELD_NAME = 'detected_level';
export const TABLE_LEVEL_FIELD_NAME = 'level';
export const LogDetailsBody = (props: Props) => {
const showField = () => {
const { onClickShowField, row } = props;
@@ -21,22 +21,6 @@ interface Props {
export const ActiveFields = ({ activeFields, clear, fields, reorder, suggestedFields, toggle }: Props) => {
const styles = useStyles2(getLogsFieldsStyles);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) {
return;
}
const newActiveFields = [...activeFields];
const element = activeFields[result.source.index];
newActiveFields.splice(result.source.index, 1);
newActiveFields.splice(result.destination.index, 0, element);
reorder(newActiveFields);
},
[activeFields, reorder]
);
const active = useMemo(
() => [
...activeFields
@@ -48,6 +32,47 @@ export const ActiveFields = ({ activeFields, clear, fields, reorder, suggestedFi
[activeFields, fields, suggestedFields]
);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) {
return;
}
// Get the field names from the active array and use that instead of the index
// This is needed because in the table and logs view some fields are not rendered, so the index is not the same as the index in the activeFields array
const sourceFieldName = active[result.source.index]?.name;
if (!sourceFieldName) {
return;
}
const newActiveFields = [...activeFields];
const sourceIndexInActiveFields = newActiveFields.indexOf(sourceFieldName);
if (sourceIndexInActiveFields === -1) {
return;
}
const [movedField] = newActiveFields.splice(sourceIndexInActiveFields, 1);
const destFieldName = active[result.destination.index]?.name;
if (destFieldName) {
const destIndexInActiveFields = newActiveFields.indexOf(destFieldName);
if (destIndexInActiveFields !== -1) {
const insertIndex =
result.source.index < result.destination.index ? destIndexInActiveFields + 1 : destIndexInActiveFields;
newActiveFields.splice(insertIndex, 0, movedField);
} else {
newActiveFields.push(movedField);
}
} else {
newActiveFields.push(movedField);
}
reorder(newActiveFields);
},
[activeFields, active, reorder]
);
const suggested = useMemo(
() => suggestedFields.filter((suggestedField) => !activeFields.includes(suggestedField.name)),
[activeFields, suggestedFields]
@@ -10,8 +10,8 @@ import { FieldNameMetaStore } from 'app/features/explore/Logs/LogsTableWrap';
import { SETTING_KEY_ROOT } from 'app/features/explore/Logs/utils/logs';
import { parseLogsFrame } from 'app/features/logs/logsFrame';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { getSuggestedFieldsForLogs } from '../otel/formats';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_DETECTED_LEVEL_FIELD_NAME } from '../LogDetailsBody';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, getSuggestedFieldsForLogs } from '../otel/formats';
import { useLogListContext } from '../panel/LogListContext';
import { reportInteractionOnce } from '../panel/analytics';
import { LogListModel } from '../panel/processing';
@@ -103,7 +103,10 @@ export const LogListFieldSelector = ({ containerElement, dataFrames, logs }: Log
);
const suggestedFields = useMemo(() => getSuggestedFields(logs, displayedFields), [displayedFields, logs]);
const fields = useMemo(() => getFieldsWithStats(dataFrames), [dataFrames]);
const fields = useMemo(
() => getFieldsWithStats(dataFrames).filter((field) => field.name !== TABLE_DETECTED_LEVEL_FIELD_NAME),
[dataFrames]
);
if (!onClickShowField || !onClickHideField || !setDisplayedFields) {
console.warn(
@@ -215,7 +218,7 @@ export const LogsTableFieldSelector = ({
const displayedColumns = useMemo(
() =>
Object.keys(columnsWithMeta)
.filter((column) => columnsWithMeta[column].active)
.filter((column) => columnsWithMeta[column].active && column !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)
.sort((a, b) =>
columnsWithMeta[a].index !== undefined && columnsWithMeta[b].index !== undefined
? columnsWithMeta[a].index - columnsWithMeta[b].index
@@ -250,7 +253,10 @@ export const LogsTableFieldSelector = ({
() => getSuggestedFields(logs, displayedColumns, defaultColumns),
[defaultColumns, displayedColumns, logs]
);
const fields = useMemo(() => getFieldsWithStats(dataFrames), [dataFrames]);
const fields = useMemo(
() => getFieldsWithStats(dataFrames).filter((field) => field.name !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME),
[dataFrames]
);
return sidebarWidth > MIN_WIDTH * 2 ? (
<FieldSelector
@@ -17,7 +17,7 @@ import { findHighlightChunksInText, GrafanaTheme2, LogsDedupStrategy, TimeRange
import { t } from '@grafana/i18n';
import { Button, Icon, Tooltip } from '@grafana/ui';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_TIME_FIELD_NAME } from '../LogDetailsBody';
import { LogLabels } from '../LogLabels';
import { LogMessageAnsi } from '../LogMessageAnsi';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
@@ -392,6 +392,10 @@ const DisplayedFields = ({
if (field === LOG_LINE_BODY_FIELD_NAME) {
return <LogLineBody log={log} key={field} styles={styles} />;
}
// Hide Time field - it's already rendered via showTime in the parent Log component
if (field === TABLE_TIME_FIELD_NAME) {
return null;
}
if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME && syntaxHighlighting) {
return (
<span className="field log-syntax-highlight" title={getNormalizedFieldName(field)} key={field}>
@@ -15,7 +15,7 @@ import {
import { config, reportInteraction } from '@grafana/runtime';
import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_TIME_FIELD_NAME, TABLE_DETECTED_LEVEL_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine, createLogRow } from '../mocks/logRow';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats';
@@ -125,12 +125,32 @@ describe('LogList', () => {
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
const logsWithDetectedLevel = [
createLogRow({ uid: '1', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'info' } }),
createLogRow({ uid: '2', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'debug' } }),
];
render(
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
<LogList
{...defaultProps}
logs={logsWithDetectedLevel}
onLogOptionsChange={onLogOptionsChange}
setDisplayedFields={setDisplayedFields}
/>
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
expect(onLogOptionsChange).not.toHaveBeenCalled();
expect(setDisplayedFields).not.toHaveBeenCalled();
// Even when OTel is disabled, we still report table defaults
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
// setDisplayedFields is called with the default fields
expect(setDisplayedFields).toHaveBeenCalledWith([
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
config.featureToggles.otelLogsFormatting = originalState;
});
@@ -140,14 +160,33 @@ describe('LogList', () => {
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
const logsWithDetectedLevel = [
createLogRow({ uid: '1', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'info' } }),
createLogRow({ uid: '2', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'debug' } }),
];
render(
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
<LogList
{...defaultProps}
logs={logsWithDetectedLevel}
onLogOptionsChange={onLogOptionsChange}
setDisplayedFields={setDisplayedFields}
/>
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', []);
// For non-OTel logs, we report table defaults only (no OTel attributes field)
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
// No fields to display, no call
expect(setDisplayedFields).not.toHaveBeenCalled();
// setDisplayedFields is called with the default fields
expect(setDisplayedFields).toHaveBeenCalledWith([
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
config.featureToggles.otelLogsFormatting = originalState;
});
@@ -157,7 +196,12 @@ describe('LogList', () => {
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
const logs = [createLogRow({ uid: '1', labels: { [OTEL_PROBE_FIELD]: '1' } })];
const logs = [
createLogRow({
uid: '1',
labels: { [OTEL_PROBE_FIELD]: '1', [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'info' },
}),
];
render(
<LogList
@@ -168,11 +212,19 @@ describe('LogList', () => {
/>
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
// For OTel logs, we report table defaults + OTel fields
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
]);
expect(setDisplayedFields).toHaveBeenCalledWith([
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
]);
expect(setDisplayedFields).toHaveBeenCalledWith([LOG_LINE_BODY_FIELD_NAME, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]);
config.featureToggles.otelLogsFormatting = originalState;
});
@@ -27,6 +27,12 @@ import { config, getDataSourceSrv } from '@grafana/runtime';
import { PopoverContent } from '@grafana/ui';
import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils';
import {
LOG_LINE_BODY_FIELD_NAME,
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
TABLE_LEVEL_FIELD_NAME,
} from '../LogDetailsBody';
import { getFieldSelectorState } from '../fieldSelector/FieldSelector';
import { getDisplayedFieldsForLogs } from '../otel/formats';
@@ -118,6 +124,27 @@ export const useLogIsPermalinked = (log: LogListModel) => {
return permalinkedLogId && permalinkedLogId === log.uid;
};
/**
* Get default table fields.
* Always returns Time, and detected_level if it exists in the logs (excluding Line).
*/
function getTableDefaultFields(logs: LogRowModel[]): string[] {
const fields: string[] = [TABLE_TIME_FIELD_NAME];
// Check if detected_level exists in any log's labels, fall back to level if not found
const hasDetectedLevel = logs.some((log) => log.labels?.[TABLE_DETECTED_LEVEL_FIELD_NAME] !== undefined);
const hasLevel = !hasDetectedLevel && logs.some((log) => log.labels?.[TABLE_LEVEL_FIELD_NAME] !== undefined);
if (hasDetectedLevel) {
fields.push(TABLE_DETECTED_LEVEL_FIELD_NAME);
} else if (hasLevel) {
// Fall back to level if detected_level is not present
fields.push(TABLE_LEVEL_FIELD_NAME);
}
return fields;
}
export type LogListState = Pick<
LogListContextData,
| 'dedupStrategy'
@@ -263,27 +290,52 @@ export const LogListContextProvider = ({
}, []);
const otelDisplayedFields = useMemo(() => {
if (!config.featureToggles.otelLogsFormatting || !setDisplayedFields || showLogAttributes === false) {
if (!config.featureToggles.otelLogsFormatting) {
return [];
}
if (showLogAttributes === false) {
return [];
}
return getDisplayedFieldsForLogs(logs);
}, [logs, setDisplayedFields, showLogAttributes]);
}, [logs, showLogAttributes]);
// OTel displayed fields
// Get table default fields
const tableDefaultFields = useMemo(() => {
return getTableDefaultFields(logs);
}, [logs]);
// Combine table defaults with OTel defaults in specific order:
// ['Time', 'detected_level', '___LOG_LINE_BODY___', '___OTEL_LOG_ATTRIBUTES___']
const defaultDisplayedFields = useMemo(() => {
const orderedFields: string[] = tableDefaultFields;
// Always add LOG_LINE_BODY before OTel fields
orderedFields.push(LOG_LINE_BODY_FIELD_NAME);
// Add OTel fields, excluding LOG_LINE_BODY_FIELD_NAME if it's already there to avoid duplicates
const otelFieldsWithoutBody = otelDisplayedFields.filter((field) => field !== LOG_LINE_BODY_FIELD_NAME);
orderedFields.push(...otelFieldsWithoutBody);
return orderedFields;
}, [tableDefaultFields, otelDisplayedFields]);
// Pass default displayed fields (table defaults + OTel defaults) to parent
useEffect(() => {
if (config.featureToggles.otelLogsFormatting && showLogAttributes !== false) {
onLogOptionsChange?.('defaultDisplayedFields', otelDisplayedFields);
if (defaultDisplayedFields.length > 0) {
onLogOptionsChange?.('defaultDisplayedFields', defaultDisplayedFields);
}
}, [onLogOptionsChange, otelDisplayedFields, showLogAttributes]);
}, [onLogOptionsChange, defaultDisplayedFields]);
// Set default displayed fields (table defaults + OTel defaults) when displayedFields is empty or missing table defaults
useEffect(() => {
if (displayedFields.length > 0 || !setDisplayedFields) {
if (!setDisplayedFields || defaultDisplayedFields.length === 0) {
return;
}
if (otelDisplayedFields.length) {
setDisplayedFields(otelDisplayedFields);
if (displayedFields.length === 0) {
setDisplayedFields(defaultDisplayedFields);
}
}, [displayedFields.length, otelDisplayedFields, setDisplayedFields]);
}, [displayedFields, defaultDisplayedFields, tableDefaultFields, setDisplayedFields]);
// Sync state
useEffect(() => {
@@ -223,6 +223,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(OLDEST_LOGS_LABEL_REGEX));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('sortOrder', LogsSortOrder.Descending);
@@ -235,6 +237,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(DEDUPE_LABEL_COPY));
await userEvent.click(screen.getByText('Numbers'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
@@ -286,6 +290,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(SHOW_TIMESTAMP_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
@@ -298,6 +304,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(WRAP_LINES_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
@@ -319,6 +327,8 @@ describe('LogListControls', () => {
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText('Wrap disabled'));
await userEvent.click(screen.getByText('Enable line wrapping'));
@@ -354,6 +364,8 @@ describe('LogListControls', () => {
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(TIMESTAMP_LABEL_COPY));
await userEvent.click(screen.getByText('Show millisecond timestamps'));
@@ -381,6 +393,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true);
+1 -1
View File
@@ -32,7 +32,7 @@ function getField(cache: FieldCache, name: string, fieldType: FieldType): FieldW
const DATAPLANE_TIMESTAMP_NAME = 'timestamp';
const DATAPLANE_BODY_NAME = 'body';
const DATAPLANE_SEVERITY_NAME = 'severity';
const DATAPLANE_ID_NAME = 'id';
export const DATAPLANE_ID_NAME = 'id';
const DATAPLANE_LABELS_NAME = 'labels';
// NOTE: this is a hot fn, we need to avoid allocating new objects here
+13 -9
View File
@@ -156,30 +156,34 @@ export const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => {
return 0;
};
export function sortLogRows(logRows: LogRowModel[], sortOrder: LogsSortOrder) {
return sortOrder === LogsSortOrder.Ascending
? logRows.sort(sortInAscendingOrder)
: logRows.sort(sortInDescendingOrder);
}
export const sortLogsResult = (logsResult: LogsModel | null, sortOrder: LogsSortOrder): LogsModel => {
const rows = logsResult ? sortLogRows(logsResult.rows, sortOrder) : [];
return logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows };
};
export const sortLogRows = (logRows: LogRowModel[], sortOrder: LogsSortOrder) =>
sortOrder === LogsSortOrder.Ascending ? logRows.sort(sortInAscendingOrder) : logRows.sort(sortInDescendingOrder);
// Currently supports only error condition in Loki logs
export const checkLogsError = (logRow: LogRowModel): string | undefined => {
export function checkLogsError(logRow: LogRowModel): string | undefined {
return logRow.labels.__error__;
};
}
export const checkLogsSampled = (logRow: LogRowModel): string | undefined => {
export function checkLogsSampled(logRow: LogRowModel): string | undefined {
if (!logRow.labels.__adaptive_logs_sampled__) {
return undefined;
}
return logRow.labels.__adaptive_logs_sampled__ === 'true'
? 'Logs like this one have been dropped by Adaptive Logs'
: `${logRow.labels.__adaptive_logs_sampled__}% of logs like this one have been dropped by Adaptive Logs`;
};
}
export const escapeUnescapedString = (string: string) =>
string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n'));
export function escapeUnescapedString(string: string) {
return string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n'));
}
export function logRowsToReadableJson(logs: LogRowModel[], pickFields: string[] = []) {
return logs.map((log) => {
@@ -762,6 +762,19 @@ describe('Tempo service graph view', () => {
]);
});
it('should escape span with multi line content correctly', () => {
const spanContent = [
`
SELECT * from "my_table"
WHERE "data_enabled" = 1
ORDER BY "name" ASC`,
];
let escaped = getEscapedRegexValues(getEscapedValues(spanContent));
expect(escaped).toEqual([
'\\n SELECT \\\\* from \\"my_table\\"\\n WHERE \\"data_enabled\\" = 1\\n ORDER BY \\"name\\" ASC',
]);
});
it('should get field config correctly', () => {
let datasourceUid = 's4Jvz8Qnk';
let tempoDatasourceUid = 'EbPO1fYnz';
@@ -1168,7 +1168,7 @@ export function getEscapedRegexValues(values: string[]) {
}
export function getEscapedValues(values: string[]) {
return values.map((value: string) => value.replace(/["\\]/g, '\\$&'));
return values.map((value: string) => value.replace(/["\\]/g, '\\$&').replace(/[\n]/g, '\\n'));
}
export function getFieldConfig(
+1
View File
@@ -293,6 +293,7 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
updated?: string;
updated_by?: UpdatedBy | null;
version?: number;
message?: string;
}
// types for Grafana-managed recording and alerting rules
+1
View File
@@ -4416,6 +4416,7 @@
},
"no-properties-changed": "No relevant properties changed",
"table": {
"notes": "Notes",
"updated": "Date",
"updatedBy": "Updated By",
"version": "Version"