Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d3ac161bc | ||
|
|
50635853c8 | ||
|
|
fe985eb5d0 | ||
|
|
c037e39a1a | ||
|
|
2168453109 | ||
|
|
f04118e6c7 | ||
|
|
ddfca17d92 | ||
|
|
fa9cc6bc8c | ||
|
|
a196cc1adb | ||
|
|
70d0e9006b | ||
|
|
e8561c6b0f | ||
|
|
731704fa21 | ||
|
|
6b543d22f9 | ||
|
|
c234f41691 | ||
|
|
86a12469a7 | ||
|
|
e538c2f3c9 | ||
|
|
0a3892a52e | ||
|
|
dc20e8bb2b | ||
|
|
099c75b632 | ||
|
|
4de55500d8 | ||
|
|
05319502a0 | ||
|
|
aacaf0ca26 | ||
|
|
1e841eaba8 | ||
|
|
2efcc88e62 | ||
|
|
6fea614106 | ||
|
|
c0c05a65fd | ||
|
|
41ed2aeb23 | ||
|
|
9e9233051e | ||
|
|
a5faedbe68 |
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-1
@@ -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
|
||||
|
||||
|
+2
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+24
-2
@@ -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;
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
serializeStateToUrlParam,
|
||||
urlUtil,
|
||||
LogLevel,
|
||||
shallowCompare,
|
||||
} from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
@@ -47,6 +46,7 @@ import {
|
||||
Themeable2,
|
||||
withTheme2,
|
||||
} from '@grafana/ui';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import store from 'app/core/store';
|
||||
import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks';
|
||||
import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows';
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
} from '../ContentOutline/ContentOutlineAnalyticEvents';
|
||||
import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext';
|
||||
import { getUrlStateFromPaneState } from '../hooks/useStateSync';
|
||||
import { parseURL } from '../hooks/useStateSync/parseURL';
|
||||
import { changePanelState } from '../state/explorePane';
|
||||
import { changeQueries, runQueries } from '../state/query';
|
||||
|
||||
@@ -82,6 +83,7 @@ import { LogsMetaRow } from './LogsMetaRow';
|
||||
import LogsNavigation from './LogsNavigation';
|
||||
import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap';
|
||||
import { LogsVolumePanelList } from './LogsVolumePanelList';
|
||||
import { migrateLegacyColumns } from './utils/columnMigration';
|
||||
import { SETTING_KEY_ROOT, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs';
|
||||
import { getExploreBaseUrl } from './utils/url';
|
||||
|
||||
@@ -201,8 +203,9 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
panelState?.logs?.sortOrder ?? store.get(SETTINGS_KEYS.logsSortOrder) ?? LogsSortOrder.Descending
|
||||
);
|
||||
const [isFlipping, setIsFlipping] = useState<boolean>(false);
|
||||
const [displayedFields, setDisplayedFields] = useState<string[]>(panelState?.logs?.displayedFields ?? []);
|
||||
const [defaultDisplayedFields, setDefaultDisplayedFields] = useState<string[]>([]);
|
||||
// Use Redux state as single source of truth
|
||||
const displayedFields = useMemo(() => panelState?.logs?.displayedFields ?? [], [panelState?.logs?.displayedFields]);
|
||||
const [contextOpen, setContextOpen] = useState<boolean>(false);
|
||||
const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined);
|
||||
const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState<PopoverContent>(PINNED_LOGS_MESSAGE);
|
||||
@@ -212,6 +215,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
const logsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const dispatch = useDispatch();
|
||||
const previousLoading = usePrevious(loading);
|
||||
const { location } = useGrafana();
|
||||
|
||||
const logsVolumeEventBus = eventBus.newScopedBus('logsvolume', { onlyLocal: false });
|
||||
const { register, unregister, outlineItems, updateItem } = useContentOutlineContext() ?? {};
|
||||
@@ -322,7 +326,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
dispatch(
|
||||
changePanelState(exploreId, 'logs', {
|
||||
...state.panelsState.logs,
|
||||
columns: logsPanelState.columns ?? panelState?.logs?.columns,
|
||||
visualisationType: logsPanelState.visualisationType ?? visualisationType,
|
||||
labelFieldName: logsPanelState.labelFieldName,
|
||||
refId: logsPanelState.refId ?? panelState?.logs?.refId,
|
||||
@@ -336,7 +339,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
[
|
||||
dispatch,
|
||||
exploreId,
|
||||
panelState?.logs?.columns,
|
||||
panelState?.logs?.displayedFields,
|
||||
panelState?.logs?.refId,
|
||||
panelState?.logs?.tableSortBy,
|
||||
@@ -345,14 +347,38 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
]
|
||||
);
|
||||
|
||||
// Migration: Convert legacy 'columns' parameter from URL to 'displayedFields'
|
||||
useEffect(() => {
|
||||
if (!shallowCompare(displayedFields, panelState?.logs?.displayedFields ?? [])) {
|
||||
updatePanelState({
|
||||
...panelState?.logs,
|
||||
displayedFields,
|
||||
});
|
||||
// Parse URL to check for legacy columns
|
||||
const urlParams = location.getSearchObject();
|
||||
const [urlState] = parseURL(urlParams);
|
||||
|
||||
// Find the pane - exploreId might not match the URL pane key directly
|
||||
const urlPane = urlState.panes[exploreId] ?? Object.values(urlState.panes)[0];
|
||||
|
||||
if (!urlPane?.panelsState?.logs) {
|
||||
return;
|
||||
}
|
||||
}, [displayedFields, panelState?.logs, updatePanelState]);
|
||||
|
||||
// Get current displayedFields to use as defaults for merge
|
||||
const currentDisplayedFields = displayedFields;
|
||||
|
||||
// Use migration utility to parse and transform legacy columns
|
||||
const mergedFields = migrateLegacyColumns(urlPane.panelsState.logs, currentDisplayedFields, visualisationType);
|
||||
if (!mergedFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update displayedFields in Redux state - URL sync will handle URL update
|
||||
dispatch(
|
||||
changePanelState(exploreId, 'logs', {
|
||||
...panelState?.logs,
|
||||
columns: undefined, // Remove columns from URL
|
||||
displayedFields: mergedFields,
|
||||
})
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Run only on mount
|
||||
|
||||
// actions
|
||||
const onLogRowHover = useCallback(
|
||||
@@ -541,30 +567,48 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
|
||||
const showField = useCallback(
|
||||
(key: string) => {
|
||||
const index = displayedFields.indexOf(key);
|
||||
const currentFields = panelState?.logs?.displayedFields ?? [];
|
||||
const index = currentFields.indexOf(key);
|
||||
|
||||
if (index === -1) {
|
||||
const updatedDisplayedFields = displayedFields.concat(key);
|
||||
setDisplayedFields(updatedDisplayedFields);
|
||||
const updatedDisplayedFields = currentFields.concat(key);
|
||||
updatePanelState({
|
||||
displayedFields: updatedDisplayedFields,
|
||||
});
|
||||
}
|
||||
},
|
||||
[displayedFields]
|
||||
[panelState?.logs?.displayedFields, updatePanelState]
|
||||
);
|
||||
|
||||
const hideField = useCallback(
|
||||
(key: string) => {
|
||||
const index = displayedFields.indexOf(key);
|
||||
const currentFields = panelState?.logs?.displayedFields ?? [];
|
||||
const index = currentFields.indexOf(key);
|
||||
if (index > -1) {
|
||||
const updatedDisplayedFields = displayedFields.filter((k) => key !== k);
|
||||
setDisplayedFields(updatedDisplayedFields);
|
||||
const updatedDisplayedFields = currentFields.filter((k) => key !== k);
|
||||
updatePanelState({
|
||||
displayedFields: updatedDisplayedFields,
|
||||
});
|
||||
}
|
||||
},
|
||||
[displayedFields]
|
||||
[panelState?.logs?.displayedFields, updatePanelState]
|
||||
);
|
||||
|
||||
const clearDisplayedFields = useCallback(() => {
|
||||
setDisplayedFields([]);
|
||||
}, []);
|
||||
updatePanelState({
|
||||
displayedFields: defaultDisplayedFields,
|
||||
});
|
||||
}, [defaultDisplayedFields, updatePanelState]);
|
||||
|
||||
// Wrapper function for setDisplayedFields prop - updates Redux directly
|
||||
const setDisplayedFields = useCallback(
|
||||
(fields: string[]) => {
|
||||
updatePanelState({
|
||||
displayedFields: fields,
|
||||
});
|
||||
},
|
||||
[updatePanelState]
|
||||
);
|
||||
|
||||
const onCloseCallbackRef = useRef<() => void>(() => {});
|
||||
|
||||
@@ -1003,6 +1047,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
updatePanelState={updatePanelState}
|
||||
datasourceType={props.datasourceType}
|
||||
displayedFields={displayedFields}
|
||||
defaultDisplayedFields={defaultDisplayedFields}
|
||||
exploreId={props.exploreId}
|
||||
absoluteRange={props.absoluteRange}
|
||||
logRows={props.logRows}
|
||||
@@ -1042,6 +1087,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
getFieldLinks={getFieldLinks}
|
||||
logsSortOrder={logsSortOrder}
|
||||
displayedFields={displayedFields}
|
||||
defaultDisplayedFields={defaultDisplayedFields}
|
||||
onClickShowField={showField}
|
||||
onClickHideField={hideField}
|
||||
app={CoreApp.Explore}
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('LogsMetaRow', () => {
|
||||
});
|
||||
|
||||
it('renders the show original line button', () => {
|
||||
setup({ displayedFields: ['test'] });
|
||||
setup({ displayedFields: ['test'], defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'] });
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Show original line',
|
||||
@@ -66,13 +66,20 @@ describe('LogsMetaRow', () => {
|
||||
});
|
||||
|
||||
it('renders the displayed fields', async () => {
|
||||
setup({ displayedFields: ['testField1234'] });
|
||||
setup({
|
||||
displayedFields: ['testField1234'],
|
||||
defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'],
|
||||
});
|
||||
expect(await screen.findByText('testField1234')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a button to clear displayedfields', () => {
|
||||
const clearSpy = jest.fn();
|
||||
setup({ displayedFields: ['testField1234'], clearDisplayedFields: clearSpy });
|
||||
setup({
|
||||
displayedFields: ['testField1234'],
|
||||
defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'],
|
||||
clearDisplayedFields: clearSpy,
|
||||
});
|
||||
fireEvent(
|
||||
screen.getByRole('button', {
|
||||
name: 'Show original line',
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
LogsDedupStrategy,
|
||||
LogsMetaItem,
|
||||
LogsMetaKind,
|
||||
LogRowModel,
|
||||
CoreApp,
|
||||
Labels,
|
||||
store,
|
||||
shallowCompare,
|
||||
} from '@grafana/data';
|
||||
import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels, store } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||
@@ -58,6 +49,14 @@ export const LogsMetaRow = memo(
|
||||
}: Props) => {
|
||||
const style = useStyles2(getStyles);
|
||||
|
||||
// Filter out default fields from displayedFields to show only user-added fields
|
||||
const nonDefaultFields = useMemo(() => {
|
||||
if (!displayedFields?.length || !defaultDisplayedFields?.length) {
|
||||
return [];
|
||||
}
|
||||
return displayedFields.filter((field) => !defaultDisplayedFields.includes(field));
|
||||
}, [displayedFields, defaultDisplayedFields]);
|
||||
|
||||
const logsMetaItem: Array<LogsMetaItem | MetaItemProps> = [...meta];
|
||||
|
||||
// Add deduplication info
|
||||
@@ -69,16 +68,12 @@ export const LogsMetaRow = memo(
|
||||
});
|
||||
}
|
||||
|
||||
// Add detected fields info
|
||||
if (
|
||||
visualisationType === 'logs' &&
|
||||
displayedFields?.length > 0 &&
|
||||
shallowCompare(displayedFields, defaultDisplayedFields) === false
|
||||
) {
|
||||
// Add detected fields info - only show when user has added fields beyond defaults
|
||||
if (visualisationType === 'logs' && nonDefaultFields.length > 0) {
|
||||
logsMetaItem.push(
|
||||
{
|
||||
label: t('explore.logs-meta-row.label.showing-only-selected-fields', 'Showing only selected fields'),
|
||||
value: <LogLabelsList labels={displayedFields} />,
|
||||
value: <LogLabelsList labels={nonDefaultFields} />,
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
|
||||
@@ -33,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4416,6 +4416,7 @@
|
||||
},
|
||||
"no-properties-changed": "No relevant properties changed",
|
||||
"table": {
|
||||
"notes": "Notes",
|
||||
"updated": "Date",
|
||||
"updatedBy": "Updated By",
|
||||
"version": "Version"
|
||||
|
||||
Reference in New Issue
Block a user