diff --git a/e2e-playwright/panels-suite/canvas-scene.spec.ts b/e2e-playwright/panels-suite/canvas-scene.spec.ts index b1fc028f3ae..c0b392d544b 100644 --- a/e2e-playwright/panels-suite/canvas-scene.spec.ts +++ b/e2e-playwright/panels-suite/canvas-scene.spec.ts @@ -2,18 +2,16 @@ import { Locator } from '@playwright/test'; import { test, expect } from '@grafana/plugin-e2e'; -import { setVisualization } from './vizpicker-utils'; - test.use({ featureToggles: { canvasPanelPanZoom: true, }, }); test.describe('Canvas Panel - Scene Tests', () => { - test.beforeEach(async ({ page, gotoDashboardPage, selectors }) => { + test.beforeEach(async ({ page, gotoDashboardPage }) => { const dashboardPage = await gotoDashboardPage({}); const panelEditPage = await dashboardPage.addPanel(); - await setVisualization(panelEditPage, 'Canvas', selectors); + await panelEditPage.setVisualization('Canvas'); // Wait for canvas panel to load await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 }); diff --git a/e2e-playwright/panels-suite/vizpicker-utils.ts b/e2e-playwright/panels-suite/vizpicker-utils.ts deleted file mode 100644 index 1785dd7e04a..00000000000 --- a/e2e-playwright/panels-suite/vizpicker-utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, E2ESelectorGroups, PanelEditPage } from '@grafana/plugin-e2e'; - -// this replaces the panelEditPage.setVisualization method used previously in tests, since it -// does not know how to use the updated 12.4 viz picker UI to set the visualization -export const setVisualization = async (panelEditPage: PanelEditPage, vizName: string, selectors: E2ESelectorGroups) => { - const vizPicker = panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker); - await expect(vizPicker, '"Change" button should be visible').toBeVisible(); - await vizPicker.click(); - - const allVizTabBtn = panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('All visualizations')); - await expect(allVizTabBtn, '"All visualiations" button should be visible').toBeVisible(); - await allVizTabBtn.click(); - - const vizItem = panelEditPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(vizName)); - await expect(vizItem, `"${vizName}" item should be visible`).toBeVisible(); - await vizItem.scrollIntoViewIfNeeded(); - await vizItem.click(); - - await expect(vizPicker, '"Change" button should be visible again').toBeVisible(); - await expect( - panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header), - 'Panel header should have the new viz type name' - ).toHaveText(vizName); -}; diff --git a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts index 336dbef0a29..0133a3e3712 100644 --- a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts +++ b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from '@grafana/plugin-e2e'; -import { setVisualization } from '../../../panels-suite/vizpicker-utils'; import { formatExpectError } from '../errors'; import { successfulDataQuery } from '../mocks/queries'; @@ -25,10 +24,10 @@ test.describe( ).toContainText(['Field', 'Max', 'Mean', 'Last']); }); - test('table panel data assertions', async ({ panelEditPage, selectors }) => { + test('table panel data assertions', async ({ panelEditPage }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, 'Table', selectors); + await panelEditPage.setVisualization('Table'); await panelEditPage.refreshPanel(); await expect( panelEditPage.panel.locator, @@ -44,10 +43,10 @@ test.describe( ).toContainText(['val1', 'val2', 'val3', 'val4']); }); - test('timeseries panel - table view assertions', async ({ panelEditPage, selectors }) => { + test('timeseries panel - table view assertions', async ({ panelEditPage }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, 'Time series', selectors); + await panelEditPage.setVisualization('Time series'); await panelEditPage.refreshPanel(); await panelEditPage.toggleTableView(); await expect( diff --git a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts index 93e0525ab0e..46c36277848 100644 --- a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts +++ b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from '@grafana/plugin-e2e'; -import { setVisualization } from '../../../panels-suite/vizpicker-utils'; import { formatExpectError } from '../errors'; import { successfulDataQuery } from '../mocks/queries'; import { scenarios } from '../mocks/resources'; @@ -54,10 +53,10 @@ test.describe( ).toHaveText(scenarios.map((s) => s.name)); }); - test('mocked query data response', async ({ panelEditPage, page, selectors }) => { + test('mocked query data response', async ({ panelEditPage, page }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors); + await panelEditPage.setVisualization(TABLE_VIZ_NAME); await panelEditPage.refreshPanel(); await expect( panelEditPage.panel.getErrorIcon(), @@ -76,7 +75,7 @@ test.describe( selectors, page, }) => { - await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors); + await panelEditPage.setVisualization(TABLE_VIZ_NAME); await expect( panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header), formatExpectError('Expected panel visualization to be set to table') @@ -93,8 +92,8 @@ test.describe( ).toBeVisible(); }); - test('Select time zone in timezone picker', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('Select time zone in timezone picker', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = await panelEditPage.getCustomOptions('Axis'); const timeZonePicker = axisOptions.getSelect('Time zone'); @@ -102,8 +101,8 @@ test.describe( await expect(timeZonePicker).toHaveSelected('Europe/Stockholm'); }); - test('select unit in unit picker', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('select unit in unit picker', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const standardOptions = panelEditPage.getStandardOptions(); const unitPicker = standardOptions.getUnitPicker('Unit'); @@ -112,8 +111,8 @@ test.describe( await expect(unitPicker).toHaveSelected('Pixels'); }); - test('enter value in number input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in number input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const lineWith = axisOptions.getNumberInput('Soft min'); @@ -122,8 +121,8 @@ test.describe( await expect(lineWith).toHaveValue('10'); }); - test('enter value in slider', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in slider', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const graphOptions = panelEditPage.getCustomOptions('Graph styles'); const lineWidth = graphOptions.getSliderInput('Line width'); @@ -132,8 +131,8 @@ test.describe( await expect(lineWidth).toHaveValue('10'); }); - test('select value in single value select', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('select value in single value select', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const standardOptions = panelEditPage.getStandardOptions(); const colorSchemeSelect = standardOptions.getSelect('Color scheme'); @@ -141,8 +140,8 @@ test.describe( await expect(colorSchemeSelect).toHaveSelected('Classic palette'); }); - test('clear input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('clear input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const panelOptions = panelEditPage.getPanelOptions(); const title = panelOptions.getTextInput('Title'); @@ -151,8 +150,8 @@ test.describe( await expect(title).toHaveValue(''); }); - test('enter value in input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const panelOptions = panelEditPage.getPanelOptions(); const description = panelOptions.getTextInput('Description'); @@ -161,8 +160,8 @@ test.describe( await expect(description).toHaveValue('This is a panel'); }); - test('unchecking switch', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('unchecking switch', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const showBorder = axisOptions.getSwitch('Show border'); @@ -174,8 +173,8 @@ test.describe( await expect(showBorder).toBeChecked({ checked: false }); }); - test('checking switch', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('checking switch', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const showBorder = axisOptions.getSwitch('Show border'); @@ -184,8 +183,8 @@ test.describe( await expect(showBorder).toBeChecked(); }); - test('re-selecting value in radio button group', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('re-selecting value in radio button group', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const placement = axisOptions.getRadioGroup('Placement'); @@ -196,8 +195,8 @@ test.describe( await expect(placement).toHaveChecked('Auto'); }); - test('selecting value in radio button group', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('selecting value in radio button group', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const placement = axisOptions.getRadioGroup('Placement'); diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 981b10dfb1c..04b0b28847c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -421,6 +421,10 @@ export interface FeatureToggles { */ jitterAlertRulesWithinGroups?: boolean; /** + * Enable audit logging with Kubernetes under app platform + */ + auditLoggingAppPlatform?: boolean; + /** * Enable the secrets management API and services under app platform */ secretsManagementAppPlatform?: boolean; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx index f57fbb59bd6..a91179fad3e 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx @@ -48,7 +48,7 @@ describe('MetricsModal', () => { operations: [], }; - setup(query, ['with-labels'], true); + setup(query, ['with-labels']); await waitFor(() => { expect(screen.getByText('with-labels')).toBeInTheDocument(); }); @@ -220,6 +220,10 @@ function createDatasource(withLabels?: boolean) { // display different results if their labels are selected in the PromVisualQuery if (withLabels) { languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue({ + ALERTS: { + type: 'gauge', + help: 'alerts help text', + }, 'with-labels': { type: 'with-labels-type', help: 'with-labels-help', @@ -297,7 +301,7 @@ function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, m }; } -function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) { +function setup(query: PromVisualQuery, metrics: string[]) { const withLabels: boolean = query.labels.length > 0; const datasource = createDatasource(withLabels); const props = createProps(query, datasource, metrics); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx index 59c4c703ccf..bf92a3ddc77 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx @@ -138,7 +138,7 @@ const MetricsModalContent = (props: MetricsModalProps) => { export const MetricsModal = (props: MetricsModalProps) => { return ( - + ); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx index 955b2c1b585..46082f476b5 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx @@ -4,6 +4,7 @@ import { ReactNode } from 'react'; import { TimeRange } from '@grafana/data'; import { PrometheusLanguageProviderInterface } from '../../../language_provider'; +import { getMockTimeRange } from '../../../test/mocks/datasource'; import { DEFAULT_RESULTS_PER_PAGE, MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext'; import { generateMetricData } from './helpers'; @@ -25,7 +26,9 @@ const mockLanguageProvider: PrometheusLanguageProviderInterface = { // Helper to create wrapper component const createWrapper = (languageProvider = mockLanguageProvider) => { return ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ); }; @@ -167,6 +170,7 @@ describe('MetricsModalContext', () => { it('should handle empty metadata response', async () => { (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({}); + (mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['metric1', 'metric2']); const { result } = renderHook(() => useMetricsModal(), { wrapper: createWrapper(), @@ -176,7 +180,18 @@ describe('MetricsModalContext', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.filteredMetricsData).toEqual([]); + expect(result.current.filteredMetricsData).toEqual([ + { + value: 'metric1', + type: 'counter', + description: 'Test metric', + }, + { + value: 'metric2', + type: 'counter', + description: 'Test metric', + }, + ]); }); it('should handle metadata fetch error', async () => { @@ -239,6 +254,7 @@ describe('MetricsModalContext', () => { })); (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({ + ALERTS: { type: 'gauge', help: 'Test alerts help' }, test_metric: { type: 'counter', help: 'Test metric' }, }); @@ -250,7 +266,7 @@ describe('MetricsModalContext', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.filteredMetricsData).toHaveLength(1); + expect(result.current.filteredMetricsData).toHaveLength(2); expect(result.current.selectedTypes).toEqual([]); }); @@ -318,7 +334,7 @@ describe('MetricsModalContext', () => { }; const { getByTestId } = render( - + ); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx index 3361b448547..117e3aad56e 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx @@ -52,11 +52,13 @@ const MetricsModalContext = createContext( type MetricsModalContextProviderProps = { languageProvider: PrometheusLanguageProviderInterface; + timeRange: TimeRange; }; export const MetricsModalContextProvider: FC> = ({ children, languageProvider, + timeRange, }) => { const [isLoading, setIsLoading] = useState(true); const [metricsData, setMetricsData] = useState([]); @@ -111,8 +113,16 @@ export const MetricsModalContextProvider: FC generateMetricData(m, languageProvider)); + setMetricsData(processedData); } else { const processedData = Object.keys(metadata).map((m) => generateMetricData(m, languageProvider)); setMetricsData(processedData); @@ -122,7 +132,7 @@ export const MetricsModalContextProvider: FC diff --git a/pkg/services/auth/auth.go b/pkg/services/auth/auth.go index cc678914b31..76b60b68517 100644 --- a/pkg/services/auth/auth.go +++ b/pkg/services/auth/auth.go @@ -20,9 +20,10 @@ const ( // Typed errors var ( - ErrUserTokenNotFound = errors.New("user token not found") - ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken - ErrExternalSessionNotFound = errors.New("external session not found") + ErrUserTokenNotFound = errors.New("user token not found") + ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken + ErrExternalSessionNotFound = errors.New("external session not found") + ErrExternalSessionTokenNotFound = errors.New("session token was nil") ) type ( diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d6f2bcbec2e..22e832034bc 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -688,6 +688,14 @@ var ( HideFromDocs: true, RequiresRestart: true, }, + { + Name: "auditLoggingAppPlatform", + Description: "Enable audit logging with Kubernetes under app platform", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + HideFromDocs: true, + RequiresRestart: true, + }, { Name: "secretsManagementAppPlatform", Description: "Enable the secrets management API and services under app platform", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 179568aa0c4..87001f263f8 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -95,6 +95,7 @@ kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad 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 +auditLoggingAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,true,false secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,false,false secretsManagementAppPlatformUI,experimental,@grafana/grafana-operator-experience-squad,false,false,false alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 2797b046d57..6543d31dba5 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -279,6 +279,10 @@ const ( // Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group. Disables sequential evaluation if enabled. FlagJitterAlertRulesWithinGroups = "jitterAlertRulesWithinGroups" + // FlagAuditLoggingAppPlatform + // Enable audit logging with Kubernetes under app platform + FlagAuditLoggingAppPlatform = "auditLoggingAppPlatform" + // FlagSecretsManagementAppPlatform // Enable the secrets management API and services under app platform FlagSecretsManagementAppPlatform = "secretsManagementAppPlatform" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 42922ecf82d..5bea1b2e40f 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -658,6 +658,20 @@ "frontend": true } }, + { + "metadata": { + "name": "auditLoggingAppPlatform", + "resourceVersion": "1767013056996", + "creationTimestamp": "2025-12-29T12:57:36Z" + }, + "spec": { + "description": "Enable audit logging with Kubernetes under app platform", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "requiresRestart": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "authZGRPCServer", diff --git a/pkg/services/oauthtoken/oauth_token.go b/pkg/services/oauthtoken/oauth_token.go index 0efe5e553f3..6d320251ccc 100644 --- a/pkg/services/oauthtoken/oauth_token.go +++ b/pkg/services/oauthtoken/oauth_token.go @@ -660,6 +660,10 @@ func (o *Service) getExternalSession(ctx context.Context, usr identity.Requester return externalSessions[0], nil } + if sessionToken == nil { + return nil, auth.ErrExternalSessionTokenNotFound + } + // For regular users, we use the session token ID to fetch the external session return o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId) } diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx index a90e9dc52a8..cbc5563538f 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx @@ -60,4 +60,76 @@ describe('LogRecordViewerByTimestamp', () => { expect(within(errorRows[1]).getByText(/Error message:/)).toBeInTheDocument(); expect(within(errorRows[1]).getByText(/explicit message/)).toBeInTheDocument(); }); + + describe('Numeric Value Formatting', () => { + it('should format numeric values correctly in AlertInstanceValues', () => { + const records: LogRecord[] = [ + { + timestamp: 1681739580000, + line: { + current: 'Alerting', + previous: 'Pending', + labels: {}, + values: { + cpu_usage: 42.987654321, + memory_mb: 1234567.89, + disk_io: 0.001234, + request_count: 10000, + }, + }, + }, + ]; + + render(); + + expect(screen.getByText(/cpu_usage/)).toBeInTheDocument(); + expect(screen.getByText(/4\.299e\+1/i)).toBeInTheDocument(); + + expect(screen.getByText(/memory_mb/)).toBeInTheDocument(); + expect(screen.getByText(/1\.235e\+6/i)).toBeInTheDocument(); + + expect(screen.getByText(/disk_io/)).toBeInTheDocument(); + expect(screen.getByText(/1\.234e-3/i)).toBeInTheDocument(); + + expect(screen.getByText(/request_count/)).toBeInTheDocument(); + expect(screen.getByText(/10000/)).toBeInTheDocument(); + }); + + it('should format various numeric ranges correctly', () => { + const records: LogRecord[] = [ + { + timestamp: 1681739580000, + line: { + current: 'Alerting', + previous: 'Pending', + labels: {}, + values: { + small: 0.001, + normal: 42.5, + large: 123456, + boundary_low: 0.01, + boundary_high: 10000, + }, + }, + }, + ]; + + render(); + + expect(screen.getByText(/small/)).toBeInTheDocument(); + expect(screen.getByText(/1\.000e-3/i)).toBeInTheDocument(); + + expect(screen.getByText(/normal/)).toBeInTheDocument(); + expect(screen.getByText(/42\.5/)).toBeInTheDocument(); + + expect(screen.getByText(/large/)).toBeInTheDocument(); + expect(screen.getByText(/1\.235e\+5/i)).toBeInTheDocument(); + + expect(screen.getByText(/boundary_low/)).toBeInTheDocument(); + expect(screen.getByText(/0\.01/)).toBeInTheDocument(); + + expect(screen.getByText(/boundary_high/)).toBeInTheDocument(); + expect(screen.getByText(/10000/)).toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx index c1d90347c74..06fcde4a1ae 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx @@ -13,6 +13,7 @@ import { AlertStateTag } from '../AlertStateTag'; import { ErrorMessageRow } from './ErrorMessageRow'; import { LogRecord, omitLabels } from './common'; +import { formatNumericValue } from './numberFormatter'; type LogRecordViewerProps = { records: LogRecord[]; @@ -182,7 +183,7 @@ const AlertInstanceValues = memo(({ record }: { record: Record } return ( <> {values.map(([key, value]) => ( - + ))} ); diff --git a/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts new file mode 100644 index 00000000000..77dfe40df5a --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts @@ -0,0 +1,173 @@ +import { formatNumericValue } from './numberFormatter'; + +describe('formatNumericValue', () => { + describe('Zero and special values', () => { + it('should format zero correctly', () => { + expect(formatNumericValue(0)).toBe('0'); + expect(formatNumericValue(-0)).toBe('0'); + }); + + it('should handle NaN', () => { + expect(formatNumericValue(NaN)).toBe('NaN'); + }); + + it('should handle Infinity', () => { + expect(formatNumericValue(Infinity)).toBe('Infinity'); + expect(formatNumericValue(-Infinity)).toBe('-Infinity'); + }); + }); + + describe('Very small numbers (scientific notation)', () => { + it('should use scientific notation for values less than 1e-2', () => { + const result1 = formatNumericValue(1e-3); + expect(result1).toMatch(/^1\.000e-3$/i); + + const result2 = formatNumericValue(0.001); + expect(result2).toMatch(/^1\.000e-3$/i); + + const result3 = formatNumericValue(0.009); + expect(result3).toMatch(/^9\.000e-3$/i); + }); + + it('should use scientific notation for values just below 1e-2', () => { + const result = formatNumericValue(0.00999); + expect(result).toMatch(/^9\.990e-3$/i); + }); + + it('should format the example from requirements correctly', () => { + // 1.4153928131348452 has > 4 decimal places, so should use scientific notation + const result = formatNumericValue(1.4153928131348452); + expect(result).toMatch(/^1\.415e\+0$/i); + }); + + it('should handle negative very small numbers', () => { + const result = formatNumericValue(-1e-3); + expect(result).toMatch(/^-1\.000e-3$/i); + + const result2 = formatNumericValue(-0.001); + expect(result2).toMatch(/^-1\.000e-3$/i); + }); + }); + + describe('Human-readable range (standard notation)', () => { + it('should use standard notation for boundary value 1e-2', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + }); + + it('should use standard notation for values in readable range', () => { + expect(formatNumericValue(0.1)).toBe('0.1'); + expect(formatNumericValue(1)).toBe('1'); + expect(formatNumericValue(1.234)).toBe('1.234'); + expect(formatNumericValue(42.5)).toBe('42.5'); + }); + + it('should limit to 4 decimal places without rounding integer parts', () => { + expect(formatNumericValue(123.456)).toBe('123.456'); + expect(formatNumericValue(1234.567)).toBe('1234.567'); + expect(formatNumericValue(9999.9)).toBe('9999.9'); + expect(formatNumericValue(9999.1234)).toBe('9999.1234'); + }); + + it('should use scientific notation for numbers with more than 4 decimal places', () => { + // Numbers with > 4 decimals should use scientific notation even in readable range + const result1 = formatNumericValue(123.456789); + expect(result1).toMatch(/^1\.235e\+2$/i); + + const result2 = formatNumericValue(1.23456789); + expect(result2).toMatch(/^1\.235e\+0$/i); + + const result3 = formatNumericValue(42.987654321); + expect(result3).toMatch(/^4\.299e\+1$/i); + }); + + it('should use standard notation for boundary value 1e4', () => { + expect(formatNumericValue(10000)).toBe('10000'); + }); + + it('should handle negative numbers in readable range', () => { + expect(formatNumericValue(-0.1)).toBe('-0.1'); + expect(formatNumericValue(-123.456)).toBe('-123.456'); + expect(formatNumericValue(-9999.9)).toBe('-9999.9'); + }); + + it('should use scientific notation for negative numbers with excessive precision', () => { + const result = formatNumericValue(-42.987654321); + expect(result).toMatch(/^-4\.299e\+1$/i); + }); + }); + + describe('Very large numbers (scientific notation)', () => { + it('should use scientific notation for values greater than 1e4', () => { + const result1 = formatNumericValue(10001); + expect(result1).toMatch(/^1\.000e\+4$/i); + + const result2 = formatNumericValue(123456); + expect(result2).toMatch(/^1\.235e\+5$/i); + }); + + it('should handle negative very large numbers', () => { + const result = formatNumericValue(-1e5); + expect(result).toMatch(/^-1\.000e\+5$/i); + + const result2 = formatNumericValue(-123456); + expect(result2).toMatch(/^-1\.235e\+5$/i); + }); + }); + + describe('Edge cases', () => { + it('should handle numbers exactly at boundaries', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + + const justBelow = formatNumericValue(0.009999); + expect(justBelow).toMatch(/^9\.999e-3$/i); + + expect(formatNumericValue(10000)).toBe('10000'); + + const justAbove = formatNumericValue(10001); + expect(justAbove).toMatch(/^1\.000e\+4$/i); + }); + + it('should use scientific notation for very precise decimals with > 4 decimal places', () => { + expect(formatNumericValue(1.23456789)).toMatch(/^1\.235e\+0$/i); + expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i); + expect(formatNumericValue(0.123456789)).toMatch(/^1\.235e-1$/i); + }); + + it('should use standard notation for numbers with exactly 4 or fewer decimal places', () => { + expect(formatNumericValue(1.2345)).toBe('1.2345'); + expect(formatNumericValue(0.1234)).toBe('0.1234'); + expect(formatNumericValue(123.4567)).toBe('123.4567'); + }); + }); + + describe('countDecimalPlaces edge cases', () => { + it('should handle numbers that toString() would convert to scientific notation', () => { + const result = formatNumericValue(1e-10); + expect(result).toMatch(/^1\.000e-10$/i); + + const result2 = formatNumericValue(1e10); + expect(result2).toMatch(/^1\.000e\+10$/i); + }); + + it('should correctly count decimals for numbers with trailing zeros', () => { + expect(formatNumericValue(1.234)).toBe('1.234'); + expect(formatNumericValue(1.2)).toBe('1.2'); + expect(formatNumericValue(1.0)).toBe('1'); + }); + + it('should handle boundary values correctly', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + expect(formatNumericValue(10000)).toBe('10000'); + + expect(formatNumericValue(0.01001)).toMatch(/^1\.001e-2$/i); + expect(formatNumericValue(9999.1234)).toBe('9999.1234'); + expect(formatNumericValue(9999.12345)).toMatch(/^9\.999e\+3$/i); + }); + + it('should handle numbers in readable range that have many decimals', () => { + expect(formatNumericValue(1.4153928131348452)).toMatch(/^1\.415e\+0$/i); + expect(formatNumericValue(42.987654321)).toMatch(/^4\.299e\+1$/i); + expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts new file mode 100644 index 00000000000..8e518c2c932 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts @@ -0,0 +1,75 @@ +const SCIENTIFIC_NOTATION_THRESHOLD_SMALL = 1e-2; +const SCIENTIFIC_NOTATION_THRESHOLD_LARGE = 1e4; +const MAX_DECIMAL_PLACES = 4; +const EXPONENTIAL_DECIMALS = 3; // 4 significant digits = 1 digit + 3 decimals + +const readableRangeFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: MAX_DECIMAL_PLACES, + useGrouping: false, +}); + +/** + * Counts the number of decimal places in a number. + * Only processes numbers in readable range (1e-2 to 1e4) to avoid + * toString() scientific notation issues for very large/small numbers. + * + * Uses toFixed(10) to ensure standard notation representation. + * 10 decimal places is sufficient to detect if a number has > 4 decimal places. + */ +function countDecimalPlaces(value: number): number { + if (Number.isInteger(value)) { + return 0; + } + + const absValue = Math.abs(value); + + // Only count decimals for numbers in readable range + if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) { + return 0; + } + + const str = value.toFixed(10); + const decimalIndex = str.indexOf('.'); + + if (decimalIndex === -1) { + return 0; + } + + // Count decimal places, removing trailing zeros + const decimalPart = str.substring(decimalIndex + 1).replace(/0+$/, ''); + return decimalPart.length; +} + +/** + * Formats a numeric value for display in alert rule history. + * - For values in human-readable range (1e-2 to 1e4) with ≤ 4 decimal places: shows up to 4 decimal places + * - For very small values (< 1e-2): uses scientific notation with 4 significant digits + * - For very large values (> 1e4): uses scientific notation with 4 significant digits + * - For numbers with > 4 decimal places: uses scientific notation with 4 significant digits + * + * @param value - The number to format + * @returns A formatted string representation of the number + */ +export function formatNumericValue(value: number): string { + if (!Number.isFinite(value)) { + return String(value); + } + + if (value === 0) { + return '0'; + } + + const absValue = Math.abs(value); + + if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) { + return value.toExponential(EXPONENTIAL_DECIMALS); + } + + const decimalPlaces = countDecimalPlaces(value); + + if (decimalPlaces > MAX_DECIMAL_PLACES) { + return value.toExponential(EXPONENTIAL_DECIMALS); + } + + return readableRangeFormatter.format(value); +}