Compare commits

..

18 Commits

Author SHA1 Message Date
Mihaly Gyongyosi 9becd8e634 Merge remote-tracking branch 'origin/main' into mgyongyosi/teamsync-use-app-ptf-apis 2026-01-07 17:39:45 +01:00
Tom Ratcliffe 88746cf96c Refactor to avoid rule of hooks violations
(cherry picked from commit e2d459afdd)
2026-01-07 17:36:27 +01:00
Paulo Dias e116254f32 Alerting: Update createdBy field when silence is being Recreated (#115543) 2026-01-07 16:05:53 +00:00
Matheus Macabu 2efcc88e62 FeatureToggles: Remove unused kubernetesFeatureToggles (#115933) 2026-01-07 15:53:58 +01:00
Mihaly Gyongyosi 27c464c833 Address feedback 2026-01-07 15:44:14 +01:00
Mihaly Gyongyosi e1efcae8f3 Revert "Use the search endpoint for listing"
This reverts commit 65cee8e6b4.
2026-01-07 15:32:24 +01:00
Galen Kistler 6fea614106 LogsTable: Inspect button fix (#115912)
* fix: inspect button

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

* Alerting: Update version history tests for message field

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

---------

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

adds paragraph about saved searches functionality

* typo and explainer

details on default search option

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

* Explore: fix test for escape character of break-line in Traceql in Search tab
2026-01-07 13:31:29 +01:00
Mihaly Gyongyosi 1f32a31c2b Merge remote-tracking branch 'origin/main' into mgyongyosi/teamsync-use-app-ptf-apis 2026-01-07 10:58:20 +01:00
Mihaly Gyongyosi 65cee8e6b4 Use the search endpoint for listing 2026-01-06 12:37:48 +01:00
Mihaly Gyongyosi 6da8c5d1bb Fix typecheck 2026-01-05 16:58:07 +01:00
Mihaly Gyongyosi 87f09ffa2d Merge remote-tracking branch 'origin/main' into mgyongyosi/teamsync-use-app-ptf-apis 2026-01-05 16:08:43 +01:00
Mihaly Gyongyosi e0b76f7916 Add tests 2026-01-05 14:55:52 +01:00
Mihaly Gyongyosi 87b32fe05c Merge remote-tracking branch 'origin/main' into mgyongyosi/teamsync-use-app-ptf-apis 2026-01-05 12:21:04 +01:00
Mihaly Gyongyosi 649fabb2e6 Use the new APIs if the ft is enabled 2025-12-19 17:20:29 +01:00
23 changed files with 414 additions and 124 deletions
@@ -41,9 +41,13 @@ Select a group to expand it and view the list of alert rules within that group.
The list view includes a number of filters to simplify managing large volumes of alerts.
## Filter and save searches
Click the **Filter** button to open the filter popup. You can filter by name, label, folder/namespace, evaluation group, data source, contact point, rule source, rule state, rule type, and the health of the alert rule from the popup menu. Click **Apply** at the bottom of the filter popup to enact the filters as you search.
{{< figure src="/media/docs/alerting/alerting-list-view-filter.png" max-width="750px" alt="Alert rule filter options" >}}
Click the **Saved searches** button to open the list of previously saved searches, or click **+ Save current search** to add your current search to the saved searches list. You can also rename a saved search or set it as a default search. When you set a saved search as the default search, the Alert rules page opens with the search applied.
{{< figure src="/media/docs/alerting/alerting-saved-searches.png" max-width="750px" alt="Alert rule filter options" >}}
## Change alert rules list view
@@ -23,6 +23,8 @@ killercoda:
This tutorial is a continuation of the [Get started with Grafana Alerting - Route alerts using dynamic labels](http://www.grafana.com/tutorials/alerting-get-started-pt5/) tutorial.
{{< youtube id="mqj_hN24zLU" >}}
<!-- USE CASE -->
In this tutorial you will learn how to:
+3 -3
View File
@@ -29,7 +29,7 @@ require (
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // @grafana/grafana-backend-group
github.com/alicebob/miniredis/v2 v2.34.0 // @grafana/alerting-backend
github.com/andybalholm/brotli v1.2.0 // @grafana/partner-datasources
github.com/apache/arrow-go/v18 v18.5.0 // @grafana/plugins-platform-backend
github.com/apache/arrow-go/v18 v18.4.1 // @grafana/plugins-platform-backend
github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad
github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources
@@ -448,7 +448,7 @@ require (
github.com/gomodule/redigo v1.8.9 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.26.1 // indirect
github.com/google/flatbuffers v25.9.23+incompatible // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-github/v64 v64.0.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
@@ -497,7 +497,7 @@ require (
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/asmfmt v1.3.2 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
+6 -6
View File
@@ -814,8 +814,8 @@ github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUS
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/apache/arrow-go/v18 v18.5.0 h1:rmhKjVA+MKVnQIMi/qnM0OxeY4tmHlN3/Pvu+Itmd6s=
github.com/apache/arrow-go/v18 v18.5.0/go.mod h1:F1/wPb3bUy6ZdP4kEPWC7GUZm+yDmxXFERK6uDSkhr8=
github.com/apache/arrow-go/v18 v18.4.1 h1:q/jVkBWCJOB9reDgaIZIdruLQUb1kbkvOnOFezVH1C4=
github.com/apache/arrow-go/v18 v18.4.1/go.mod h1:tLyFubsAl17bvFdUAy24bsSvA/6ww95Iqi67fTpGu3E=
github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
@@ -1501,8 +1501,8 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/gnostic v0.7.1 h1:t5Kc7j/8kYr8t2u11rykRrPPovlEMG4+xdc/SpekATs=
github.com/google/gnostic v0.7.1/go.mod h1:KSw6sxnxEBFM8jLPfJd46xZP+yQcfE8XkiqfZx5zR28=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
@@ -1929,8 +1929,8 @@ github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e
github.com/klauspost/compress v1.15.2/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
-4
View File
@@ -400,10 +400,6 @@ export interface FeatureToggles {
*/
tableSharedCrosshair?: boolean;
/**
* Use the kubernetes API for feature toggle management in the frontend
*/
kubernetesFeatureToggles?: boolean;
/**
* Enabled grafana cloud specific RBAC roles
*/
cloudRBACRoles?: boolean;
@@ -1,5 +1,7 @@
import { HttpResponse, http } from 'msw';
import { mockTeamsMap } from '../../../../fixtures/teams';
const getDisplayMapping = () =>
http.get<{ namespace: string }>('/apis/iam.grafana.app/v0alpha1/namespaces/:namespace/display', ({ request }) => {
const url = new URL(request.url);
@@ -26,4 +28,76 @@ const getDisplayMapping = () =>
});
});
export default [getDisplayMapping()];
const listExternalGroupMappings = () =>
http.get<{ namespace: string }>('/apis/iam.grafana.app/v0alpha1/namespaces/:namespace/externalgroupmappings', () => {
const items = [];
for (const [teamName, data] of mockTeamsMap.entries()) {
for (const group of data.groups) {
items.push({
apiVersion: 'iam.grafana.app/v0alpha1',
kind: 'ExternalGroupMapping',
metadata: {
name: `mapping-${teamName}-${group.groupId}`,
creationTimestamp: new Date().toISOString(),
},
spec: {
externalGroupId: group.groupId,
teamRef: {
name: teamName,
},
},
});
}
}
return HttpResponse.json({ items });
});
const createExternalGroupMapping = () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
http.post<{ namespace: string }, any>(
'/apis/iam.grafana.app/v0alpha1/namespaces/:namespace/externalgroupmappings',
async ({ request }) => {
const body = await request.json();
const teamName = body.spec.teamRef.name;
const groupId = body.spec.externalGroupId;
const teamData = mockTeamsMap.get(teamName);
if (teamData) {
teamData.groups.push({ groupId });
}
return HttpResponse.json({
...body,
metadata: {
name: `mapping-${teamName}-${groupId}`,
creationTimestamp: new Date().toISOString(),
...body.metadata,
},
});
}
);
const deleteExternalGroupMapping = () =>
http.delete<{ namespace: string; name: string }>(
'/apis/iam.grafana.app/v0alpha1/namespaces/:namespace/externalgroupmappings/:name',
({ params }) => {
const { name } = params;
for (const [teamName, data] of mockTeamsMap.entries()) {
const groupIndex = data.groups.findIndex((g) => `mapping-${teamName}-${g.groupId}` === name);
if (groupIndex !== -1) {
data.groups.splice(groupIndex, 1);
return HttpResponse.json({ status: 'Success' });
}
}
return HttpResponse.json({ status: 'Failure', message: 'Not found' }, { status: 404 });
}
);
export default [
getDisplayMapping(),
listExternalGroupMappings(),
createExternalGroupMapping(),
deleteExternalGroupMapping(),
];
-7
View File
@@ -650,13 +650,6 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaDatavizSquad,
},
{
Name: "kubernetesFeatureToggles",
Description: "Use the kubernetes API for feature toggle management in the frontend",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaOperatorExperienceSquad,
},
{
Name: "cloudRBACRoles",
Description: "Enabled grafana cloud specific RBAC roles",
-1
View File
@@ -90,7 +90,6 @@ pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
timeComparison,experimental,@grafana/dataviz-squad,false,false,true
tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true
kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true
cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false
alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false
jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
90 canvasPanelPanZoom preview @grafana/dataviz-squad false false true
91 timeComparison experimental @grafana/dataviz-squad false false true
92 tableSharedCrosshair experimental @grafana/dataviz-squad false false true
kubernetesFeatureToggles experimental @grafana/grafana-operator-experience-squad false false true
93 cloudRBACRoles preview @grafana/identity-access-team false true false
94 alertingQueryOptimization GA @grafana/alerting-squad false false false
95 jitterAlertRulesWithinGroups preview @grafana/alerting-squad false true false
+2 -1
View File
@@ -2044,7 +2044,8 @@
"metadata": {
"name": "kubernetesFeatureToggles",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-01-18T05:32:44Z"
"creationTimestamp": "2024-01-18T05:32:44Z",
"deletionTimestamp": "2026-01-07T12:02:51Z"
},
"spec": {
"description": "Use the kubernetes API for feature toggle management in the frontend",
@@ -3,7 +3,8 @@ import { render, screen, userEvent, waitFor } from 'test/test-utils';
import { byLabelText, byRole, byText } from 'testing-library-selector';
import { setPluginLinksHook } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import server from '@grafana/test-utils/server';
import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types/accessControl';
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
@@ -22,6 +23,7 @@ import {
mockPluginLinkExtension,
mockPromAlertingRule,
mockRulerGrafanaRecordingRule,
mockRulerGrafanaRule,
} from '../../mocks';
import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
import { grantPermissionsHelper } from '../../test/test-utils';
@@ -130,6 +132,8 @@ const dataSources = {
};
describe('RuleViewer', () => {
const api = mockAlertRuleApi(server);
beforeEach(() => {
setupDataSources(...Object.values(dataSources));
});
@@ -249,19 +253,22 @@ describe('RuleViewer', () => {
expect(screen.getAllByRole('row')).toHaveLength(7);
expect(screen.getAllByRole('row')[1]).toHaveTextContent(/6Provisioning2025-01-18 04:35:17/i);
expect(screen.getAllByRole('row')[1]).toHaveTextContent('+3-3Latest');
expect(screen.getAllByRole('row')[1]).toHaveTextContent('Updated by provisioning service');
expect(screen.getAllByRole('row')[1]).toHaveTextContent('+4-3Latest');
expect(screen.getAllByRole('row')[2]).toHaveTextContent(/5Alerting2025-01-17 04:35:17/i);
expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-5');
expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-6');
expect(screen.getAllByRole('row')[3]).toHaveTextContent(/4different user2025-01-16 04:35:17/i);
expect(screen.getAllByRole('row')[3]).toHaveTextContent('+5-5');
expect(screen.getAllByRole('row')[3]).toHaveTextContent('Changed alert title and thresholds');
expect(screen.getAllByRole('row')[3]).toHaveTextContent('+6-5');
expect(screen.getAllByRole('row')[4]).toHaveTextContent(/3user12025-01-15 04:35:17/i);
expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-9');
expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-10');
expect(screen.getAllByRole('row')[5]).toHaveTextContent(/2User ID foo2025-01-14 04:35:17/i);
expect(screen.getAllByRole('row')[5]).toHaveTextContent('+11-7');
expect(screen.getAllByRole('row')[5]).toHaveTextContent('Updated evaluation interval and routing');
expect(screen.getAllByRole('row')[5]).toHaveTextContent('+12-7');
expect(screen.getAllByRole('row')[6]).toHaveTextContent(/1Unknown 2025-01-13 04:35:17/i);
@@ -275,9 +282,10 @@ describe('RuleViewer', () => {
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled();
expect(screen.getByRole('cell', { name: /provisioning/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /alerting/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /Unknown/i })).toBeInTheDocument();
// Check for special updated_by values - use getAllByRole since some text appears in multiple columns
expect(screen.getAllByRole('cell', { name: /provisioning/i }).length).toBeGreaterThan(0);
expect(screen.getByRole('cell', { name: /^alerting$/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /^Unknown$/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /user id foo/i })).toBeInTheDocument();
});
@@ -321,6 +329,47 @@ describe('RuleViewer', () => {
await renderRuleViewer(rule, ruleIdentifier);
expect(screen.queryByText('Labels')).not.toBeInTheDocument();
});
it('shows Notes column when versions have messages', async () => {
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
expect(await screen.findByRole('columnheader', { name: /Notes/i })).toBeInTheDocument();
expect(screen.getAllByRole('row')).toHaveLength(7); // 1 header + 6 data rows
expect(screen.getByRole('cell', { name: /Updated by provisioning service/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /Changed alert title and thresholds/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /Updated evaluation interval and routing/i })).toBeInTheDocument();
});
it('does not show Notes column when no versions have messages', async () => {
const versionsWithoutMessages = [
mockRulerGrafanaRule(
{},
{
uid: grafanaRulerRule.grafana_alert.uid,
version: 2,
updated: '2025-01-14T09:35:17.000Z',
updated_by: { uid: 'foo', name: '' },
}
),
mockRulerGrafanaRule(
{},
{
uid: grafanaRulerRule.grafana_alert.uid,
version: 1,
updated: '2025-01-13T09:35:17.000Z',
updated_by: null,
}
),
];
api.getAlertRuleVersionHistory(grafanaRulerRule.grafana_alert.uid, versionsWithoutMessages);
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
await screen.findByRole('button', { name: /Compare versions/i });
expect(screen.getAllByRole('row')).toHaveLength(3); // 1 header + 2 data rows
expect(screen.queryByRole('columnheader', { name: /Notes/i })).not.toBeInTheDocument();
});
});
});
@@ -1,8 +1,9 @@
import { css } from '@emotion/css';
import { useMemo, useState } from 'react';
import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text } from '@grafana/ui';
import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text, useStyles2 } from '@grafana/ui';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { computeVersionDiff } from 'app/features/alerting/unified/utils/diff';
import { RuleIdentifier } from 'app/types/unified-alerting';
@@ -33,6 +34,7 @@ export function VersionHistoryTable({
onRestoreError,
canRestore,
}: VersionHistoryTableProps) {
const styles = useStyles2(getStyles);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [ruleToRestore, setRuleToRestore] = useState<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>();
const ruleToRestoreUid = ruleToRestore?.grafana_alert?.uid ?? '';
@@ -41,6 +43,8 @@ export function VersionHistoryTable({
[ruleToRestoreUid]
);
const hasAnyNotes = useMemo(() => ruleVersions.some((v) => v.grafana_alert.message), [ruleVersions]);
const showConfirmation = (ruleToRestore: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
setShowConfirmModal(true);
setRuleToRestore(ruleToRestore);
@@ -52,6 +56,15 @@ export function VersionHistoryTable({
const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown');
const notesColumn: Column<RulerGrafanaRuleDTO<GrafanaRuleDefinition>> = {
id: 'notes',
header: t('core.versionHistory.table.notes', 'Notes'),
cell: ({ row }) => {
const message = row.original.grafana_alert.message;
return message || null;
},
};
const columns: Array<Column<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>> = [
{
disableGrow: true,
@@ -91,9 +104,12 @@ export function VersionHistoryTable({
if (!value) {
return unknown;
}
return dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')';
return (
<span className={styles.nowrap}>{dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'}</span>
);
},
},
...(hasAnyNotes ? [notesColumn] : []),
{
id: 'diff',
disableGrow: true,
@@ -179,3 +195,9 @@ export function VersionHistoryTable({
</>
);
}
const getStyles = () => ({
nowrap: css({
whiteSpace: 'nowrap',
}),
});
@@ -47,7 +47,7 @@ export const getFormFieldsForSilence = (silence: Silence): SilenceFormFields =>
startsAt: interval.start.toISOString(),
endsAt: interval.end.toISOString(),
comment: silence.comment,
createdBy: silence.createdBy,
createdBy: isExpired ? contextSrv.user.name : silence.createdBy,
duration: intervalToAbbreviatedDurationString(interval),
isRegex: false,
matchers: silence.matchers?.map(matcherToMatcherField) || [],
@@ -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;
@@ -33,7 +33,7 @@ import {
useStyles2,
} from '@grafana/ui';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal';
import { LogsFrame } from 'app/features/logs/logsFrame';
import { DATAPLANE_ID_NAME, LogsFrame } from 'app/features/logs/logsFrame';
import { getFieldLinksForExplore } from '../utils/links';
@@ -154,9 +154,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 +180,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 +202,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)}
@@ -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,25 +28,20 @@ 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 lineValue = getLineValue();
const getLineValue = () => {
const logRowById = logRows?.find((row) => row.rowId === logId);
return logRowById?.raw ?? '';
};
const styles = getStyles(theme);
@@ -105,33 +100,29 @@ export function LogsTableActionButtons(props: Props) {
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}
/>
<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}
/>
</div>
{isInspecting && (
<Modal
@@ -139,9 +130,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 +140,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 +153,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',
}),
});
+1 -1
View File
@@ -32,7 +32,7 @@ function getField(cache: FieldCache, name: string, fieldType: FieldType): FieldW
const DATAPLANE_TIMESTAMP_NAME = 'timestamp';
const DATAPLANE_BODY_NAME = 'body';
const DATAPLANE_SEVERITY_NAME = 'severity';
const DATAPLANE_ID_NAME = 'id';
export const DATAPLANE_ID_NAME = 'id';
const DATAPLANE_LABELS_NAME = 'labels';
// NOTE: this is a hot fn, we need to avoid allocating new objects here
@@ -1,6 +1,6 @@
import { render, screen, waitFor } from 'test/test-utils';
import { setBackendSrv } from '@grafana/runtime';
import { setBackendSrv, config } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { MOCK_TEAMS, MOCK_TEAM_GROUPS } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
@@ -25,9 +25,8 @@ describe('TeamGroupSync', () => {
expect(await screen.findAllByRole('row')).toHaveLength(MOCK_TEAM_GROUPS.length + 1); // items plus table header
});
it('should call add group', async () => {
it('should add group', async () => {
const { user } = setup();
// Wait for the groups to load so the "Add group" button appears
await screen.findAllByRole('row');
await user.click(screen.getAllByRole('button', { name: /add group/i })[0]);
@@ -43,10 +42,54 @@ describe('TeamGroupSync', () => {
const { user } = setup();
const groupToRemove = MOCK_TEAM_GROUPS[0].groupId;
// Wait for group to be rendered
await screen.findByRole('row', { name: new RegExp(groupToRemove, 'i') });
// Remove group
await user.click(screen.getByRole('button', { name: `Remove group ${groupToRemove}` }));
await waitFor(() =>
expect(screen.queryByRole('row', { name: new RegExp(groupToRemove, 'i') })).not.toBeInTheDocument()
);
});
});
describe('TeamGroupSync with kubernetesExternalGroupMapping enabled', () => {
const originalFeatureToggles = { ...config.featureToggles };
const originalNamespace = config.namespace;
beforeAll(() => {
config.featureToggles.kubernetesExternalGroupMapping = true;
config.namespace = 'default';
});
afterAll(() => {
config.featureToggles = originalFeatureToggles;
config.namespace = originalNamespace;
});
it('should render groups table', async () => {
setup();
expect(await screen.findAllByRole('row')).toHaveLength(MOCK_TEAM_GROUPS.length + 1); // items plus table header
});
it('should add group', async () => {
const { user } = setup();
await screen.findAllByRole('row');
await user.click(screen.getAllByRole('button', { name: /add group/i })[0]);
expect(screen.getByRole('textbox', { name: /add external group/i })).toBeVisible();
await user.type(screen.getByRole('textbox', { name: /add external group/i }), 'new-group');
await user.click(screen.getAllByRole('button', { name: /add group/i })[1]);
expect(await screen.findByRole('row', { name: /new-group/i })).toBeInTheDocument();
});
it('should remove group', async () => {
const { user } = setup();
const groupToRemove = MOCK_TEAM_GROUPS[0].groupId;
await screen.findByRole('row', { name: new RegExp(groupToRemove, 'i') });
await user.click(screen.getByRole('button', { name: `Remove group ${groupToRemove}` }));
await waitFor(() =>
+11 -13
View File
@@ -1,12 +1,7 @@
import { css, cx } from '@emotion/css';
import { FormEventHandler, useState } from 'react';
import {
TeamGroupDto,
useAddTeamGroupApiMutation,
useGetTeamGroupsApiQuery,
useRemoveTeamGroupApiQueryMutation,
} from '@grafana/api-clients/rtkq/legacy';
import { TeamGroupDto } from '@grafana/api-clients/rtkq/legacy';
import { Trans, t } from '@grafana/i18n';
import { Input, Tooltip, Icon, Button, useTheme2, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui';
import { SlideDown } from 'app/core/components/Animations/SlideDown';
@@ -15,6 +10,8 @@ import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { UpgradeBox, UpgradeContent, UpgradeContentProps } from 'app/core/components/Upgrade/UpgradeBox';
import { highlightTrial } from 'app/features/admin/utils';
import { useAddExternalGroupMapping, useGetExternalGroupMappings, useRemoveExternalGroupMapping } from './hooks';
interface Props {
isReadOnly: boolean;
teamUid: string;
@@ -27,9 +24,9 @@ export const TeamGroupSync = ({ isReadOnly, teamUid }: Props) => {
const [newGroupId, setNewGroupId] = useState('');
const styles = useStyles2(getStyles);
const { data: groups = [] } = useGetTeamGroupsApiQuery({ teamId: teamUid });
const [addTeamGroup] = useAddTeamGroupApiMutation();
const [removeTeamGroup] = useRemoveTeamGroupApiQueryMutation();
const { data: groups = [] } = useGetExternalGroupMappings({ teamId: teamUid });
const [addTeamGroup] = useAddExternalGroupMapping();
const [removeTeamGroup] = useRemoveExternalGroupMapping();
const onToggleAdding = () => {
setIsAddBoxVisible(!isAddBoxVisible);
@@ -46,11 +43,12 @@ export const TeamGroupSync = ({ isReadOnly, teamUid }: Props) => {
setNewGroupId('');
};
const onRemoveGroup = async (groupId: string | undefined) => {
if (!groupId) {
const onRemoveGroup = async (group: TeamGroupDto) => {
if (!group.groupId) {
return;
}
await removeTeamGroup({ teamId: teamUid, groupId });
// group.uid is always defined here because it comes from the API
await removeTeamGroup({ teamId: teamUid, groupId: group.groupId, uid: group.uid! });
};
const isNewGroupValid = () => {
@@ -65,7 +63,7 @@ export const TeamGroupSync = ({ isReadOnly, teamUid }: Props) => {
<Button
size="sm"
variant="destructive"
onClick={() => onRemoveGroup(group.groupId)}
onClick={() => onRemoveGroup(group)}
disabled={isReadOnly}
aria-label={t('teams.team-group-sync.aria-label-remove', 'Remove group {{groupName}}', {
groupName: group.groupId,
+80
View File
@@ -1,6 +1,18 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useEffect, useMemo } from 'react';
import {
useCreateExternalGroupMappingMutation,
useListExternalGroupMappingQuery,
useDeleteExternalGroupMappingMutation,
} from '@grafana/api-clients/rtkq/iam/v0alpha1';
import {
useAddTeamGroupApiMutation,
useGetTeamGroupsApiQuery,
useRemoveTeamGroupApiQueryMutation,
TeamGroupDto,
} from '@grafana/api-clients/rtkq/legacy';
import { config } from '@grafana/runtime';
import {
useSearchTeamsQuery as useLegacySearchTeamsQuery,
useCreateTeamMutation,
@@ -152,3 +164,71 @@ export const useCreateTeam = () => {
return [trigger, response] as const;
};
export const useGetExternalGroupMappings = (args: { teamId: string }) => {
const shouldUseAppPlatform = Boolean(config.featureToggles.kubernetesExternalGroupMapping);
const legacyResult = useGetTeamGroupsApiQuery(args, { skip: shouldUseAppPlatform });
const { data: newApiData, ...newApiRest } = useListExternalGroupMappingQuery({}, { skip: !shouldUseAppPlatform });
const groups: TeamGroupDto[] = useMemo(() => {
// FIXME: Consider using the search API which has sorting support
return (newApiData?.items || [])
.filter((item) => item.spec.teamRef.name === args.teamId)
.map((item) => ({
groupId: item.spec.externalGroupId,
uid: item.metadata.name,
}));
}, [newApiData, args.teamId]);
if (shouldUseAppPlatform) {
return {
...newApiRest,
data: groups,
};
}
return legacyResult;
};
export const useAddExternalGroupMapping = () => {
const legacyMutation = useAddTeamGroupApiMutation();
const [addNew, newResult] = useCreateExternalGroupMappingMutation();
const add = async (args: { teamId: string; teamGroupMapping: { groupId: string } }) => {
return addNew({
externalGroupMapping: {
metadata: {
generateName: 'external-group-mapping-',
},
spec: {
externalGroupId: args.teamGroupMapping.groupId,
teamRef: {
name: args.teamId,
},
},
},
});
};
if (!config.featureToggles.kubernetesExternalGroupMapping) {
return legacyMutation;
}
return [add, newResult] as const;
};
export const useRemoveExternalGroupMapping = () => {
const legacyMutation = useRemoveTeamGroupApiQueryMutation();
const [deleteMapping, deleteResult] = useDeleteExternalGroupMappingMutation();
const remove = async (args: { teamId: string; groupId: string; uid: string }) => {
return deleteMapping({ name: args.uid });
};
if (!config.featureToggles.kubernetesExternalGroupMapping) {
return legacyMutation;
}
return [remove, deleteResult] as const;
};
@@ -762,6 +762,19 @@ describe('Tempo service graph view', () => {
]);
});
it('should escape span with multi line content correctly', () => {
const spanContent = [
`
SELECT * from "my_table"
WHERE "data_enabled" = 1
ORDER BY "name" ASC`,
];
let escaped = getEscapedRegexValues(getEscapedValues(spanContent));
expect(escaped).toEqual([
'\\n SELECT \\\\* from \\"my_table\\"\\n WHERE \\"data_enabled\\" = 1\\n ORDER BY \\"name\\" ASC',
]);
});
it('should get field config correctly', () => {
let datasourceUid = 's4Jvz8Qnk';
let tempoDatasourceUid = 'EbPO1fYnz';
@@ -1168,7 +1168,7 @@ export function getEscapedRegexValues(values: string[]) {
}
export function getEscapedValues(values: string[]) {
return values.map((value: string) => value.replace(/["\\]/g, '\\$&'));
return values.map((value: string) => value.replace(/["\\]/g, '\\$&').replace(/[\n]/g, '\\n'));
}
export function getFieldConfig(
+1
View File
@@ -293,6 +293,7 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
updated?: string;
updated_by?: UpdatedBy | null;
version?: number;
message?: string;
}
// types for Grafana-managed recording and alerting rules
+1
View File
@@ -4416,6 +4416,7 @@
},
"no-properties-changed": "No relevant properties changed",
"table": {
"notes": "Notes",
"updated": "Date",
"updatedBy": "Updated By",
"version": "Version"