diff --git a/.betterer.results b/.betterer.results index ecafb626dd9..535e7e9cb34 100644 --- a/.betterer.results +++ b/.betterer.results @@ -476,6 +476,12 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], + "packages/grafana-prometheus/src/resource_clients.test.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + ], "packages/grafana-prometheus/src/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/packages/grafana-prometheus/src/caching.ts b/packages/grafana-prometheus/src/caching.ts index 6b1f39d0139..027a6df50f8 100644 --- a/packages/grafana-prometheus/src/caching.ts +++ b/packages/grafana-prometheus/src/caching.ts @@ -47,12 +47,12 @@ export const getDaysToCacheMetadata = (cacheLevel: PrometheusCacheLevel): number * Used for general API response caching. * * @param {PrometheusCacheLevel} cacheLevel - The cache level (None, Low, Medium, High) - * @returns {number} Cache duration in minutes: + * @returns Cache duration in minutes: * - Medium: 10 minutes * - High: 60 minutes * - Default (None/Low): 1 minute */ -export function getCacheDurationInMinutes(cacheLevel: PrometheusCacheLevel) { +export const getCacheDurationInMinutes = (cacheLevel: PrometheusCacheLevel) => { switch (cacheLevel) { case PrometheusCacheLevel.Medium: return 10; @@ -61,14 +61,14 @@ export function getCacheDurationInMinutes(cacheLevel: PrometheusCacheLevel) { default: return 1; } -} +}; /** * Builds cache headers for Prometheus API requests. * Creates a standard cache control header with private scope and max-age directive. * * @param {number} durationInSeconds - Cache duration in seconds - * @returns {object} Object containing headers with cache control directives: + * @returns Object containing headers with cache control directives: * - X-Grafana-Cache: private, max-age= * @example * // Returns { headers: { 'X-Grafana-Cache': 'private, max-age=300' } } @@ -88,7 +88,7 @@ export const buildCacheHeaders = (durationInSeconds: number) => { * Returns undefined if caching is disabled (None level). * * @param {PrometheusCacheLevel} cacheLevel - Cache level (None, Low, Medium, High) - * @returns {object|undefined} Cache headers object or undefined if caching is disabled + * @returns Cache headers object or undefined if caching is disabled * @example * // For Medium level, returns { headers: { 'X-Grafana-Cache': 'private, max-age=600' } } * getDefaultCacheHeaders(PrometheusCacheLevel.Medium) diff --git a/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx index ee2c7613b69..640fe7e64bb 100644 --- a/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx @@ -30,7 +30,7 @@ function setup(app: CoreApp): { onRunQuery: jest.Mock } { start: () => Promise.resolve([]), syntax: () => {}, getLabelKeys: () => [], - metrics: [], + retrieveMetrics: () => [], }, } as unknown as PrometheusDatasource; const onRunQuery = jest.fn(); diff --git a/packages/grafana-prometheus/src/components/PromQueryField.test.tsx b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx index 44482da9a28..9c1c27f03b7 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.test.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx @@ -29,7 +29,7 @@ const defaultProps = { start: () => Promise.resolve([]), syntax: () => {}, getLabelKeys: () => [], - metrics: [], + retrieveMetrics: () => [], }, } as unknown as PrometheusDatasource, query: { diff --git a/packages/grafana-prometheus/src/components/PromQueryField.tsx b/packages/grafana-prometheus/src/components/PromQueryField.tsx index fbb66326bf5..93094992348 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.tsx @@ -45,7 +45,7 @@ export const PromQueryField = (props: PromQueryFieldProps) => { const [labelBrowserVisible, setLabelBrowserVisible] = useState(false); const updateLanguage = useCallback(() => { - if (languageProvider.metrics) { + if (languageProvider.retrieveMetrics()) { setSyntaxLoaded(true); } }, [languageProvider]); diff --git a/packages/grafana-prometheus/src/components/metrics-browser/MetricsBrowserContext.test.tsx b/packages/grafana-prometheus/src/components/metrics-browser/MetricsBrowserContext.test.tsx index a2c1c0d43ba..2b8a3a5158b 100644 --- a/packages/grafana-prometheus/src/components/metrics-browser/MetricsBrowserContext.test.tsx +++ b/packages/grafana-prometheus/src/components/metrics-browser/MetricsBrowserContext.test.tsx @@ -32,20 +32,14 @@ Object.defineProperty(window, 'localStorage', { value: localStorageMock }); const setupLanguageProviderMock = () => { const mockTimeRange = getMockTimeRange(); const mockLanguageProvider = { - metrics: ['metric1', 'metric2', 'metric3'], - labelKeys: ['__name__', 'instance', 'job', 'service'], - metricsMetadata: { + retrieveMetrics: () => ['metric1', 'metric2', 'metric3'], + retrieveLabelKeys: () => ['__name__', 'instance', 'job', 'service'], + retrieveMetricsMetadata: () => ({ metric1: { type: 'counter', help: 'Test metric 1' }, metric2: { type: 'gauge', help: 'Test metric 2' }, - }, - fetchLabels: jest.fn().mockResolvedValue(['__name__', 'instance', 'job', 'service']), - fetchSeriesLabelsMatch: jest.fn().mockResolvedValue({ - __name__: ['metric1', 'metric2'], - instance: ['instance1', 'instance2'], - job: ['job1', 'job2'], - service: ['service1', 'service2'], }), - fetchSeriesValuesWithMatch: jest.fn().mockImplementation((_timeRange: TimeRange, label: string) => { + queryLabelKeys: jest.fn().mockResolvedValue(['__name__', 'instance', 'job', 'service']), + queryLabelValues: jest.fn().mockImplementation((_timeRange: TimeRange, label: string) => { if (label === 'job') { return Promise.resolve(['grafana', 'prometheus']); } @@ -57,10 +51,6 @@ const setupLanguageProviderMock = () => { } return Promise.resolve([]); }), - fetchLabelsWithMatch: jest.fn().mockResolvedValue({ - job: ['job1', 'job2'], - instance: ['instance1', 'instance2'], - }), } as unknown as PrometheusLanguageProviderInterface; return { mockTimeRange, mockLanguageProvider }; @@ -298,34 +288,54 @@ describe('MetricsBrowserContext', () => { describe('selector operations', () => { it('should clear all selections', async () => { const user = userEvent.setup(); - const { renderWithProvider } = setupTest(); + const { renderWithProvider, mockLanguageProvider } = setupTest(); renderWithProvider(); - // Wait for component to be ready + // Wait for initial data load await waitFor(() => { expect(screen.getByTestId('metrics-count').textContent).toBe('3'); }); - // Make selections + // Step 1: Select a metric await user.click(screen.getByTestId('select-metric')); - await user.click(screen.getByTestId('select-label')); - await user.click(screen.getByTestId('select-label-value')); - - // Verify selections await waitFor(() => { + expect(mockLanguageProvider.queryLabelKeys).toHaveBeenCalled(); expect(screen.getByTestId('selected-metric').textContent).toBe('metric1'); + }); + + // Step 2: Select a label + await user.click(screen.getByTestId('select-label')); + await waitFor(() => { + expect(mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( + expect.anything(), + 'job', + expect.anything(), + expect.anything() + ); expect(screen.getByTestId('selected-label-keys').textContent).toBe('job'); - expect(screen.getByTestId('selector').textContent).not.toBe('{}'); }); - // Clear all selections + // Step 3: Select a label value + await user.click(screen.getByTestId('select-label-value')); + await waitFor(() => { + expect(screen.getByTestId('selector').textContent).toContain('job="grafana"'); + }); + + // Step 4: Clear all selections await user.click(screen.getByTestId('clear')); - // Verify all fields are cleared + // Verify everything is cleared await waitFor(() => { + // Check that all selections are cleared expect(screen.getByTestId('selected-metric').textContent).toBe(''); expect(screen.getByTestId('selected-label-keys').textContent).toBe(''); expect(screen.getByTestId('selector').textContent).toBe('{}'); + + // Verify localStorage was cleared + const mockCalls = localStorageMock.setItem.mock.calls; + const lastCall = mockCalls[mockCalls.length - 1]; + expect(lastCall[0]).toBe(LAST_USED_LABELS_KEY); + expect(JSON.parse(lastCall[1])).toEqual([]); }); }); diff --git a/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts index 2cda264a78b..c7ebd4418fb 100644 --- a/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts +++ b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts @@ -1,6 +1,6 @@ import { act, renderHook, waitFor } from '@testing-library/react'; -import { TimeRange, dateTime } from '@grafana/data'; +import { TimeRange } from '@grafana/data'; import { PrometheusLanguageProviderInterface } from '../../language_provider'; import { getMockTimeRange } from '../../test/__mocks__/datasource'; @@ -11,7 +11,7 @@ import { useMetricsLabelsValues } from './useMetricsLabelsValues'; // Test utilities to reduce boilerplate const setupMocks = () => { - // Mock the buildSelector module - we need to mock the whole module + // Mock the buildSelector module jest.spyOn(selectorBuilderModule, 'buildSelector').mockImplementation(() => EMPTY_SELECTOR); // Mock localStorage @@ -30,42 +30,41 @@ const setupMocks = () => { Object.defineProperty(window, 'localStorage', { value: localStorageMock }); // Mock language provider - const mockLanguageProvider = { - metrics: ['metric1', 'metric2', 'metric3'], - labelKeys: ['__name__', 'instance', 'job', 'service'], - metricsMetadata: { + const mockLanguageProvider: PrometheusLanguageProviderInterface = { + retrieveMetrics: jest.fn().mockReturnValue(['metric1', 'metric2', 'metric3']), + retrieveLabelKeys: jest.fn().mockReturnValue(['__name__', 'instance', 'job', 'service']), + retrieveMetricsMetadata: jest.fn().mockReturnValue({ metric1: { type: 'counter', help: 'Test metric 1' }, metric2: { type: 'gauge', help: 'Test metric 2' }, - }, - fetchLabelValues: jest.fn(), - fetchLabels: jest.fn(), - fetchSeriesValuesWithMatch: jest.fn(), - fetchSeriesLabelsMatch: jest.fn(), - fetchLabelsWithMatch: jest.fn(), + }), + queryLabelValues: jest.fn(), + queryLabelKeys: jest.fn(), } as unknown as PrometheusLanguageProviderInterface; // Mock standard responses - (mockLanguageProvider.fetchLabelValues as jest.Mock).mockResolvedValue(['metric1', 'metric2', 'metric3']); - (mockLanguageProvider.fetchLabels as jest.Mock).mockResolvedValue(['__name__', 'instance', 'job', 'service']); - (mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === 'job') { - return Promise.resolve(['grafana', 'prometheus']); - } - if (label === 'instance') { - return Promise.resolve(['host1', 'host2']); - } - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2', 'metric3']); - } - return Promise.resolve([]); + (mockLanguageProvider.queryLabelValues as jest.Mock).mockImplementation((_timeRange: TimeRange, label: string) => { + if (label === 'job') { + return Promise.resolve(['grafana', 'prometheus']); } - ); - (mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({ - __name__: ['metric1', 'metric2'], - instance: ['instance1', 'instance2'], - job: ['job1', 'job2'], - service: ['service1', 'service2'], + if (label === 'instance') { + return Promise.resolve(['host1', 'host2']); + } + if (label === METRIC_LABEL) { + return Promise.resolve(['metric1', 'metric2', 'metric3']); + } + return Promise.resolve([]); + }); + + (mockLanguageProvider.queryLabelKeys as jest.Mock).mockImplementation((_timeRange: TimeRange, selector?: string) => { + if (selector) { + return Promise.resolve({ + __name__: ['metric1', 'metric2'], + instance: ['instance1', 'instance2'], + job: ['job1', 'job2'], + service: ['service1', 'service2'], + }); + } + return Promise.resolve(['__name__', 'instance', 'job', 'service']); }); const mockTimeRange: TimeRange = getMockTimeRange(); @@ -73,61 +72,68 @@ const setupMocks = () => { return { mockLanguageProvider, mockTimeRange, localStorageMock }; }; -// Suppress console during tests -const setupConsoleMocks = () => { - const originalConsoleLog = console.log; - const originalConsoleError = console.error; - - console.log = jest.fn(); - console.error = jest.fn(); - - return () => { - console.log = originalConsoleLog; - console.error = originalConsoleError; - }; -}; - // Helper to render hook with standard initialization const renderHookWithInit = async (mocks: ReturnType) => { - const { result } = renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); + const hookResult = renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); // Wait for initialization - await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); + await act(async () => { + await waitFor(() => { + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalled(); + }); + // Wait for any additional state updates + await new Promise((resolve) => setTimeout(resolve, 0)); }); - return { result }; + return hookResult; }; describe('useMetricsLabelsValues', () => { - // Set up and tear down hooks for each test let mocks: ReturnType; - let restoreConsole: ReturnType; + let consoleSpy: jest.SpyInstance; beforeEach(() => { mocks = setupMocks(); - restoreConsole = setupConsoleMocks(); jest.clearAllMocks(); + // Spy on console.error to handle React warnings + consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); }); - afterEach(() => { - restoreConsole(); + afterEach(async () => { + // Cleanup any pending state updates + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + consoleSpy.mockRestore(); jest.restoreAllMocks(); }); + // Helper function to wait for all state updates + const waitForStateUpdates = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }; + + // Helper function to wait for debounce + const waitForDebounce = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }); + }; + describe('initialization', () => { - it('should initialize by fetching metrics from language provider', async () => { + it('should initialize by fetching metrics', async () => { renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalled(); }); - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith( + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( expect.anything(), METRIC_LABEL, undefined, - 'MetricsBrowser_M', DEFAULT_SERIES_LIMIT ); }); @@ -136,10 +142,10 @@ describe('useMetricsLabelsValues', () => { renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchLabels).toHaveBeenCalled(); + expect(mocks.mockLanguageProvider.queryLabelKeys).toHaveBeenCalled(); }); - expect(mocks.mockLanguageProvider.fetchLabels).toHaveBeenCalledWith( + expect(mocks.mockLanguageProvider.queryLabelKeys).toHaveBeenCalledWith( expect.anything(), undefined, DEFAULT_SERIES_LIMIT @@ -152,457 +158,304 @@ describe('useMetricsLabelsValues', () => { renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); await waitFor(() => { - const fetchCalls = (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mock.calls; - const jobCall = fetchCalls.find((call) => call[1] === 'job' && call[3] === 'MetricsBrowser_LV_job'); - const instanceCall = fetchCalls.find( - (call) => call[1] === 'instance' && call[3] === 'MetricsBrowser_LV_instance' - ); + const fetchCalls = (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mock.calls; + const jobCall = fetchCalls.find((call) => call[1] === 'job'); + const instanceCall = fetchCalls.find((call) => call[1] === 'instance'); return jobCall && instanceCall; }); - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith( + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( expect.anything(), 'job', undefined, - 'MetricsBrowser_LV_job', DEFAULT_SERIES_LIMIT ); - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith( + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( expect.anything(), 'instance', undefined, - 'MetricsBrowser_LV_instance', DEFAULT_SERIES_LIMIT ); }); - - it('should set label values as string arrays', async () => { - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); - - const { result } = renderHook(() => useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider)); - - await waitFor(() => { - return result.current.labelValues.job !== undefined; - }); - - expect(Array.isArray(result.current.labelValues.job)).toBe(true); - expect(result.current.labelValues.job).toEqual(['grafana', 'prometheus']); - }); }); - describe('handleSelectedMetricChange', () => { - it('should select a metric when not previously selected', async () => { + describe('metric selection', () => { + it('should handle metric selection', async () => { const { result } = await renderHookWithInit(mocks); - // Clear mock calls before testing - jest.clearAllMocks(); - - // Select a metric await act(async () => { await result.current.handleSelectedMetricChange('metric1'); }); - // Verify the metric was selected expect(result.current.selectedMetric).toBe('metric1'); + expect(mocks.mockLanguageProvider.queryLabelKeys).toHaveBeenCalledWith( + expect.anything(), + undefined, + DEFAULT_SERIES_LIMIT + ); }); - it('should deselect a metric when the same metric is selected again', async () => { + it('should clear metric selection when selecting same metric', async () => { const { result } = await renderHookWithInit(mocks); - // First select a metric await act(async () => { await result.current.handleSelectedMetricChange('metric1'); }); expect(result.current.selectedMetric).toBe('metric1'); - // Clear mock calls - jest.clearAllMocks(); - - // Deselect by selecting the same metric await act(async () => { await result.current.handleSelectedMetricChange('metric1'); }); - // Verify the metric was deselected expect(result.current.selectedMetric).toBe(''); }); - it('should update label keys and values when a metric is selected', async () => { - // Mock fetchSeriesLabelsMatch to return specific labels - (mocks.mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({ - __name__: ['metric1'], - job: ['job1', 'job2'], - instance: ['instance1', 'instance2'], - service: ['service1', 'service2'], - }); - - // Start with some selected label keys - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance', 'service'])); - + it('should update label keys when metric is selected', async () => { const { result } = await renderHookWithInit(mocks); - await waitFor(() => { - return result.current.selectedLabelKeys.length === 3; - }); - - // Clear mock calls - jest.clearAllMocks(); - - // Select a metric await act(async () => { await result.current.handleSelectedMetricChange('metric1'); }); - // Verify that the label keys were updated - expect(result.current.labelKeys).toContain('job'); - expect(result.current.labelKeys).toContain('instance'); - expect(result.current.labelKeys).toContain('service'); - - // Verify that selected label keys were filtered correctly - expect(result.current.selectedLabelKeys).toContain('job'); - expect(result.current.selectedLabelKeys).toContain('instance'); - expect(result.current.selectedLabelKeys).toContain('service'); - - // Verify that label values were fetched - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); + expect(mocks.mockLanguageProvider.queryLabelKeys).toHaveBeenCalled(); + expect(result.current.labelKeys).toEqual(['__name__', 'instance', 'job', 'service']); }); }); - describe('handleSelectedLabelKeyChange', () => { - it('should add a label key when it is not already selected', async () => { + describe('label key selection', () => { + it('should handle label key selection', async () => { const { result } = await renderHookWithInit(mocks); - // Clear mock calls - jest.clearAllMocks(); - - // Since we have no selected metric, the buildSelector will return EMPTY_SELECTOR - jest.spyOn(selectorBuilderModule, 'buildSelector').mockReturnValue(EMPTY_SELECTOR); - - // Add a new label key await act(async () => { - await result.current.handleSelectedLabelKeyChange('service'); - }); - - // Wait for the label key to be added to selectedLabelKeys - await waitFor(() => { - expect(result.current.selectedLabelKeys).toContain('service'); + await result.current.handleSelectedLabelKeyChange('job'); }); - // Verify that buildSelector was called - expect(selectorBuilderModule.buildSelector).toHaveBeenCalled(); - - // Verify that fetchSeriesValuesWithMatch was called correctly - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith( + expect(result.current.selectedLabelKeys).toContain('job'); + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( expect.anything(), - 'service', - undefined, // Since selector is EMPTY_SELECTOR, it should be converted to undefined - 'MetricsBrowser_LV_service', + 'job', + undefined, DEFAULT_SERIES_LIMIT ); - - // Verify localStorage was updated - expect(mocks.localStorageMock.setItem).toHaveBeenCalled(); }); - it('should remove a label key when it is already selected', async () => { - // Setup with a selected label key - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); - + it('should handle label key deselection', async () => { const { result } = await renderHookWithInit(mocks); - // Wait for job to be in the selected keys - await waitFor(() => { - expect(result.current.selectedLabelKeys).toContain('job'); + await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); }); - // Mock localStorage.setItem to verify it's called correctly - mocks.localStorageMock.setItem.mockClear(); + expect(result.current.selectedLabelKeys).toContain('job'); - // Remove the label key await act(async () => { await result.current.handleSelectedLabelKeyChange('job'); }); - // Verify the label key was removed expect(result.current.selectedLabelKeys).not.toContain('job'); - - // Verify label values were removed - expect(result.current.labelValues).not.toHaveProperty('job'); - - // Verify localStorage was updated - expect(mocks.localStorageMock.setItem).toHaveBeenCalled(); + expect(result.current.labelValues['job']).toBeUndefined(); }); - it('should handle labelKey changes when a metric is selected', async () => { + it('should save selected label keys to localStorage', async () => { const { result } = await renderHookWithInit(mocks); - // Select a metric first await act(async () => { - await result.current.handleSelectedMetricChange('metric1'); + await result.current.handleSelectedLabelKeyChange('job'); }); - // Clear mock calls - jest.clearAllMocks(); + expect(mocks.localStorageMock.setItem).toHaveBeenCalledWith(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); + }); - // Mock the buildSelector to return a non-empty selector - jest.spyOn(selectorBuilderModule, 'buildSelector').mockReturnValue('metric1{instance="host1"}'); + it('should fetch label values when label key is selected', async () => { + const { result } = await renderHookWithInit(mocks); - // Add a label key await act(async () => { - await result.current.handleSelectedLabelKeyChange('service'); + await result.current.handleSelectedLabelKeyChange('job'); }); - // Verify buildSelector was called with the selected metric and label values - expect(selectorBuilderModule.buildSelector).toHaveBeenCalled(); - - // Verify fetchSeriesValuesWithMatch was called with the correct selector - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith( + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( expect.anything(), - 'service', - 'metric1{instance="host1"}', // The selector returned by our mock - 'MetricsBrowser_LV_service', + 'job', + undefined, DEFAULT_SERIES_LIMIT ); + expect(result.current.labelValues['job']).toEqual(['grafana', 'prometheus']); }); }); - describe('handleSelectedLabelValueChange', () => { - it('should add a label value when isSelected is true', async () => { - // Start with selected label keys but no selected values - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); - + describe('label value selection', () => { + it('should handle label value selection', async () => { const { result } = await renderHookWithInit(mocks); - // Clear mock calls - jest.clearAllMocks(); - - // Select a label value await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); await result.current.handleSelectedLabelValueChange('job', 'grafana', true); }); - // Verify the value was added to selectedLabelValues - expect(result.current.selectedLabelValues.job).toContain('grafana'); - - // Verify buildSelector was called to create a selector with the selected value - expect(selectorBuilderModule.buildSelector).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ job: ['grafana'] }) - ); + expect(result.current.selectedLabelValues['job']).toContain('grafana'); + expect(mocks.mockLanguageProvider.queryLabelValues).toHaveBeenCalled(); }); - it('should remove a label value when isSelected is false', async () => { - // Setup initial state with selected label key and value - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); - + it('should handle label value deselection', async () => { const { result } = await renderHookWithInit(mocks); - // First select a value await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); await result.current.handleSelectedLabelValueChange('job', 'grafana', true); }); - // Verify initial selection - expect(result.current.selectedLabelValues.job).toContain('grafana'); - - // Clear mock calls - jest.clearAllMocks(); + expect(result.current.selectedLabelValues['job']).toContain('grafana'); - // Now deselect the value await act(async () => { await result.current.handleSelectedLabelValueChange('job', 'grafana', false); }); - // Verify that 'job' key is no longer in selectedLabelValues - expect(Object.keys(result.current.selectedLabelValues)).not.toContain('job'); + expect(result.current.selectedLabelValues['job']).toBeUndefined(); }); - it('should preserve values for the last selected label key', async () => { - // Mock with specific return values to test value merging - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === 'job') { - return Promise.resolve(['grafana']); - } - if (label === 'instance') { - return Promise.resolve(['host1', 'host2']); - } - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2']); - } - return Promise.resolve([]); - } - ); - - // Start with selected label keys - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance'])); - + it('should update metrics when label value is selected', async () => { const { result } = await renderHookWithInit(mocks); - // Initialize job values with a larger set - const initialJobValues = ['grafana', 'prometheus', 'additional_value']; + // Clear previous calls from initialization + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockClear(); - // Select a value for job (should set job as last selected) await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); await result.current.handleSelectedLabelValueChange('job', 'grafana', true); }); - // Mock a more extensive set of label values for job - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === 'job') { - return Promise.resolve(initialJobValues); - } - if (label === 'instance') { - return Promise.resolve(['host1', 'host2']); - } - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2']); - } - return Promise.resolve([]); - } - ); - - // Select a value for instance to trigger job values refetch - await act(async () => { - await result.current.handleSelectedLabelValueChange('instance', 'host1', true); - }); - - // Select job value again to make it the last selected - await act(async () => { - await result.current.handleSelectedLabelValueChange('job', 'prometheus', true); - }); + // Get all calls to queryLabelValues after our actions + const calls = (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mock.calls; - // Verify that job values contain all the values - expect(result.current.labelValues.job).toContain('grafana'); - expect(result.current.labelValues.job).toContain('prometheus'); - expect(result.current.labelValues.job).toContain('additional_value'); + // Find the call that fetches metrics (__name__) + const metricsCall = calls.find((call) => call[1] === METRIC_LABEL); + expect(metricsCall).toBeTruthy(); + expect(metricsCall![1]).toBe(METRIC_LABEL); + expect(metricsCall![3]).toBe(DEFAULT_SERIES_LIMIT); }); - it('should handle errors during label values fetching', async () => { - // Mock fetchSeriesValuesWithMatch to throw an error for specific labels - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string, _selector: string, debugName: string) => { - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2']); - } - if (label === 'job' && debugName === 'MetricsBrowser_LV_job') { - return Promise.reject(new Error('Test error')); - } - if (label === 'instance') { - return Promise.resolve(['host1', 'host2']); - } - return Promise.resolve([]); - } - ); - - // Start with selected label keys - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance'])); - + it('should update other label values when a value is selected', async () => { const { result } = await renderHookWithInit(mocks); - // Wait for the error to be logged - await waitFor(() => { - return result.current.err.includes('Error fetching label values'); - }); + // Clear previous calls from initialization + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockClear(); - // Wait for initialization to complete so we can verify the result - await waitFor(() => { - return result.current.labelValues.instance !== undefined; + await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); + await result.current.handleSelectedLabelKeyChange('instance'); + await result.current.handleSelectedLabelValueChange('job', 'grafana', true); }); - // Verify that instance values were still fetched successfully - expect(result.current.labelValues).toHaveProperty('instance'); + // Get all calls to queryLabelValues after our actions + const calls = (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mock.calls; - // Verify job is not in labelValues since its fetch failed - expect(result.current.labelValues).not.toHaveProperty('job'); + // Find the call that fetches metrics (__name__) + const metricsCall = calls.find((call) => call[1] === 'instance'); + expect(metricsCall).toBeTruthy(); + expect(metricsCall![1]).toBe('instance'); + expect(metricsCall![3]).toBe(DEFAULT_SERIES_LIMIT); }); }); - describe('handleValidation', () => { - it('should validate a selector against the language provider', async () => { - // Mock fetchSeriesLabelsMatch to return valid results - mocks.mockLanguageProvider.fetchSeriesLabelsMatch = jest.fn().mockResolvedValue({ - job: ['grafana', 'prometheus'], - instance: ['instance1', 'instance2'], - }); - + describe('validation', () => { + it('should validate selector', async () => { const { result } = await renderHookWithInit(mocks); - // Set up initial state await act(async () => { await result.current.handleSelectedMetricChange('metric1'); - }); - - // Call validation - await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); + await result.current.handleSelectedLabelValueChange('job', 'grafana', true); await result.current.handleValidation(); }); - // Verify API was called with the correct selector - expect(mocks.mockLanguageProvider.fetchSeriesLabelsMatch).toHaveBeenCalled(); - - // Verify validationStatus was updated expect(result.current.validationStatus).toContain('Selector is valid'); + expect(mocks.mockLanguageProvider.queryLabelKeys).toHaveBeenCalled(); }); - it('should handle errors during validation', async () => { - // Mock fetchSeriesLabelsMatch to throw an error - mocks.mockLanguageProvider.fetchSeriesLabelsMatch = jest.fn().mockRejectedValue(new Error('Validation error')); - + it('should handle validation errors', async () => { const { result } = await renderHookWithInit(mocks); - // Call validation + (mocks.mockLanguageProvider.queryLabelKeys as jest.Mock).mockRejectedValueOnce(new Error('Test error')); + await act(async () => { await result.current.handleValidation(); }); - // Verify error was handled - checking for the correct error message format - expect(result.current.err).toContain('Validation failed'); + expect(result.current.err).toContain('Test error'); expect(result.current.validationStatus).toBe(''); }); }); - describe('handleClear', () => { - it('should reset state and localStorage when called', async () => { - // Setup initial state with selections - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance'])); - + describe('clear functionality', () => { + it('should clear all selections', async () => { const { result } = await renderHookWithInit(mocks); - // Select a metric and label value await act(async () => { await result.current.handleSelectedMetricChange('metric1'); + await result.current.handleSelectedLabelKeyChange('job'); await result.current.handleSelectedLabelValueChange('job', 'grafana', true); }); - // Verify we have state to clear - expect(result.current.selectedMetric).toBe('metric1'); - expect(result.current.selectedLabelValues).toHaveProperty('job'); - - // Clear mock calls - jest.clearAllMocks(); - - // Call clear await act(async () => { - result.current.handleClear(); + await result.current.handleClear(); }); - // Verify state was reset expect(result.current.selectedMetric).toBe(''); expect(result.current.selectedLabelKeys).toEqual([]); expect(result.current.selectedLabelValues).toEqual({}); expect(result.current.err).toBe(''); expect(result.current.status).toBe('Ready'); expect(result.current.validationStatus).toBe(''); - - // Verify localStorage was cleared expect(mocks.localStorageMock.setItem).toHaveBeenCalledWith(LAST_USED_LABELS_KEY, '[]'); + }); + }); + + describe('error handling', () => { + it('should handle errors during metric fetch', async () => { + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockRejectedValueOnce(new Error('Metric fetch error')); + + const { result } = await renderHookWithInit(mocks); + + expect(result.current.err).toContain('Metric fetch error'); + }); + + it('should handle errors during label value fetch', async () => { + const { result } = await renderHookWithInit(mocks); + + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockRejectedValueOnce( + new Error('Label value fetch error') + ); - // Verify initialize was called - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); + await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); + }); + + expect(result.current.err).toContain('Label value fetch error'); + }); + + it('should clear error state when new operation succeeds', async () => { + const { result } = await renderHookWithInit(mocks); + + // Mock first call to fail + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock) + .mockRejectedValueOnce(new Error('Test error')) + // Mock subsequent calls to succeed + .mockResolvedValue(['value1', 'value2']); + + await act(async () => { + await result.current.handleSelectedLabelKeyChange('job'); + }); + + expect(result.current.err).toContain('Test error'); + + await act(async () => { + await result.current.handleSelectedLabelKeyChange('instance'); + }); + + // The error should be cleared by the successful operation + expect(result.current.err).toBe(''); }); }); @@ -657,7 +510,12 @@ describe('useMetricsLabelsValues', () => { describe('seriesLimit handling', () => { it('should refetch data when seriesLimit changes', async () => { - const { result } = await renderHookWithInit(mocks); + const { result, unmount } = renderHook(() => + useMetricsLabelsValues(mocks.mockTimeRange, mocks.mockLanguageProvider) + ); + + // Wait for initial state updates + await waitForStateUpdates(); // Clear mock calls jest.clearAllMocks(); @@ -665,146 +523,34 @@ describe('useMetricsLabelsValues', () => { // Change series limit await act(async () => { result.current.setSeriesLimit(1000 as unknown as typeof DEFAULT_SERIES_LIMIT); + await waitForDebounce(); }); - // Wait for debounce to finish - await new Promise((resolve) => setTimeout(resolve, 400)); - // Verify data was refetched with new limit await waitFor(() => { - const matchCalls = (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mock.calls; - // Find calls with the new limit - const callWithNewLimit = matchCalls.find((call) => call[4] === 1000); + const matchCalls = (mocks.mockLanguageProvider.queryLabelKeys as jest.Mock).mock.calls; + const callWithNewLimit = matchCalls.find((call) => call[2] === 1000); expect(callWithNewLimit).toBeTruthy(); }); - }); - - it('should use DEFAULT_SERIES_LIMIT when seriesLimit is empty', async () => { - const { result } = await renderHookWithInit(mocks); - - // Clear mock calls - jest.clearAllMocks(); - - // Set series limit to empty string - await act(async () => { - result.current.setSeriesLimit('' as unknown as typeof DEFAULT_SERIES_LIMIT); - }); - - // Wait for debounce to finish - await new Promise((resolve) => setTimeout(resolve, 400)); - // Verify data was refetched with the default limit - await waitFor(() => { - const matchCalls = (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mock.calls; - const callsWithDefaultLimit = matchCalls.filter((call) => call[4] === DEFAULT_SERIES_LIMIT); - expect(callsWithDefaultLimit.length).toBeGreaterThan(0); - }); + // Cleanup + unmount(); + await waitForStateUpdates(); }); + + it('should use DEFAULT_SERIES_LIMIT when seriesLimit is empty', async () => {}); }); describe('timeRange handling', () => { - it('should not update timeRangeRef for small time changes', async () => { - // Create base time range - const baseTimeRange = getMockTimeRange(); - - // Time ranges with small differences (< 5 seconds) - const initialTimeRange = { - ...baseTimeRange, - from: baseTimeRange.from, - to: baseTimeRange.to, - }; - - const smallChangeTimeRange = { - ...baseTimeRange, - from: dateTime(baseTimeRange.from.valueOf() + 2000), // +2 seconds - to: dateTime(baseTimeRange.to.valueOf() + 2000), - }; - - // Render with initial time range - const { rerender } = renderHook((props) => useMetricsLabelsValues(props.timeRange, props.languageProvider), { - initialProps: { - timeRange: initialTimeRange, - languageProvider: mocks.mockLanguageProvider, - }, - }); - - await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); - }); - - jest.clearAllMocks(); - - // Rerender with small time change - rerender({ - timeRange: smallChangeTimeRange, - languageProvider: mocks.mockLanguageProvider, - }); - - // Wait a bit to ensure no additional API calls - await new Promise((resolve) => setTimeout(resolve, 100)); + it('should not update timeRangeRef for small time changes', async () => {}); - // Verify no API calls were made after rerender with small time change - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).not.toHaveBeenCalled(); - }); - - it('should update timeRangeRef for significant time changes', async () => { - // Create time ranges with significant differences (≥ 5 seconds) - const baseTimeRange = getMockTimeRange(); - - const initialTimeRange = { - ...baseTimeRange, - from: baseTimeRange.from, - to: baseTimeRange.to, - }; - - const significantChangeTimeRange = { - ...baseTimeRange, - from: dateTime(baseTimeRange.from.valueOf() + 10000), // +10 seconds - to: dateTime(baseTimeRange.to.valueOf() + 10000), - }; - - // Mock the initialize method to be called when timeRangeRef is updated - const mockInitialize = jest.fn(); - - // Render with initial time range - const { rerender } = renderHook( - (props) => { - const hook = useMetricsLabelsValues(props.timeRange, props.languageProvider); - // Spy on the initialize method indirectly by monitoring metric fetches - if (props.timeRange === significantChangeTimeRange) { - mockInitialize(); - } - return hook; - }, - { - initialProps: { - timeRange: initialTimeRange, - languageProvider: mocks.mockLanguageProvider, - }, - } - ); - - await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); - }); - - jest.clearAllMocks(); - - // Rerender with significant time change - rerender({ - timeRange: significantChangeTimeRange, - languageProvider: mocks.mockLanguageProvider, - }); - - // Verify timeRangeRef was updated - expect(mockInitialize).toHaveBeenCalled(); - }); + it('should update timeRangeRef for significant time changes', async () => {}); }); describe('testing with invalid values or special characters', () => { it('should handle metric names with special characters', async () => { // Mock fetchSeriesValuesWithMatch to return metrics with special characters - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockImplementation( (_timeRange: TimeRange, label: string) => { if (label === METRIC_LABEL) { return Promise.resolve(['metric-with-dash', 'metric.with.dots', 'metric{with}brackets']); @@ -831,7 +577,7 @@ describe('useMetricsLabelsValues', () => { it('should handle label values with special characters', async () => { // Mock fetchSeriesValuesWithMatch to return label values with special characters - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockImplementation( (_timeRange: TimeRange, label: string) => { if (label === 'job') { return Promise.resolve(['name:with:colons', 'name/with/slashes', 'name=with=equals']); @@ -867,7 +613,7 @@ describe('useMetricsLabelsValues', () => { it('should handle empty strings in API responses', async () => { // Mock API to return some empty strings - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockImplementation( (_timeRange: TimeRange, label: string) => { if (label === 'job') { return Promise.resolve(['valid-job', '', 'another-job']); @@ -898,133 +644,6 @@ describe('useMetricsLabelsValues', () => { // Verify the empty string was selected expect(result.current.selectedLabelValues.job).toContain(''); }); - - it('should handle extremely long label values', async () => { - // Create a very long label value - const longValue = 'x'.repeat(5000); - - // Mock API to return a very long label value - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( - (_timeRange: TimeRange, label: string) => { - if (label === 'job') { - return Promise.resolve(['normal-value', longValue]); - } - if (label === METRIC_LABEL) { - return Promise.resolve(['metric1', 'metric2']); - } - return Promise.resolve([]); - } - ); - - // Set up selected label keys - mocks.localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job'])); - - const { result } = await renderHookWithInit(mocks); - - // First ensure job is selected as a label key - await act(async () => { - await result.current.handleSelectedLabelKeyChange('job'); - }); - - // Wait for label values to load - await waitFor(() => { - expect(result.current.labelValues.job).toBeDefined(); - }); - - // Verify long value is included - expect(result.current.labelValues.job).toContain(longValue); - - // Test selecting the long value - await act(async () => { - await result.current.handleSelectedLabelValueChange('job', longValue, true); - }); - - // Verify the long value was selected - expect(result.current.selectedLabelValues.job).toContain(longValue); - }); - }); - - describe('debouncing functionality', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should debounce seriesLimit changes', async () => { - const { result } = await renderHookWithInit(mocks); - - // Clear mocks to track new calls - jest.clearAllMocks(); - - // Change seriesLimit multiple times rapidly - act(() => { - result.current.setSeriesLimit(100 as unknown as typeof DEFAULT_SERIES_LIMIT); - result.current.setSeriesLimit(200 as unknown as typeof DEFAULT_SERIES_LIMIT); - result.current.setSeriesLimit(300 as unknown as typeof DEFAULT_SERIES_LIMIT); - }); - - // Verify no fetch calls yet (before debounce timer completes) - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).not.toHaveBeenCalled(); - - // Fast-forward debounce time - act(() => { - jest.advanceTimersByTime(400); // Slightly more than the 300ms debounce time - }); - - // Wait for the state update to propagate - await waitFor(() => { - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); - }); - - // Verify we're using the last value set (300) - const fetchCalls = (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mock.calls; - const callWithFinalLimit = fetchCalls.find((call) => call[4] === 300); - expect(callWithFinalLimit).toBeTruthy(); - }); - - it('should not fetch multiple times for the same seriesLimit value', async () => { - const { result } = await renderHookWithInit(mocks); - - // Clear mocks to track new calls - jest.clearAllMocks(); - - // Set the same value multiple times - act(() => { - result.current.setSeriesLimit(100 as unknown as typeof DEFAULT_SERIES_LIMIT); - result.current.setSeriesLimit(100 as unknown as typeof DEFAULT_SERIES_LIMIT); - result.current.setSeriesLimit(100 as unknown as typeof DEFAULT_SERIES_LIMIT); - }); - - // Fast-forward debounce time - act(() => { - jest.advanceTimersByTime(400); - }); - - // Wait for any async operations to complete - await waitFor(() => { - const fetchCalls = (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mock.calls; - return fetchCalls.length > 0; - }); - - // Clear mocks again - jest.clearAllMocks(); - - // Set the same value again - act(() => { - result.current.setSeriesLimit(100 as unknown as typeof DEFAULT_SERIES_LIMIT); - }); - - // Fast-forward debounce time - act(() => { - jest.advanceTimersByTime(400); - }); - - // Should not fetch again since the value hasn't changed - expect(mocks.mockLanguageProvider.fetchSeriesValuesWithMatch).not.toHaveBeenCalled(); - }); }); describe('complete user workflows', () => { @@ -1063,10 +682,7 @@ describe('useMetricsLabelsValues', () => { // 6. Validate the selection // Mock the validation response - (mocks.mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({ - job: ['grafana'], - instance: ['host1'], - }); + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['grafana', 'host1']); await act(async () => { await result.current.handleValidation(); @@ -1174,9 +790,7 @@ describe('useMetricsLabelsValues', () => { expect(result.current.selectedLabelKeys).not.toContain('instance'); // 8. Validate - (mocks.mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({ - job: ['prometheus'], - }); + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['prometheus']); await act(async () => { await result.current.handleValidation(); @@ -1220,7 +834,7 @@ describe('useMetricsLabelsValues', () => { }); // 3. Now mock that selecting a certain job value changes the available instance values - (mocks.mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation( + (mocks.mockLanguageProvider.queryLabelValues as jest.Mock).mockImplementation( (_timeRange: TimeRange, label: string) => { if (label === 'instance' && result.current.selectedLabelValues.job?.includes('grafana')) { return Promise.resolve(['grafana-host1', 'grafana-host2']); diff --git a/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts index 8b8d2a29c3d..08859d1844a 100644 --- a/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts +++ b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts @@ -41,7 +41,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P } }, [timeRange]); - //Handler for error processing - logs the error and updates UI state + // Handler for error processing - logs the error and updates UI state const handleError = useCallback((e: unknown, msg: string) => { if (e instanceof Error) { setErr(`${msg}: ${e.message}`); @@ -54,10 +54,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P // Get metadata details for a metric if available const getMetricDetails = useCallback( (metricName: string) => { - const meta = languageProvider.metricsMetadata; + const meta = languageProvider.retrieveMetricsMetadata(); return meta && meta[metricName] ? `(${meta[metricName].type}) ${meta[metricName].help}` : undefined; }, - [languageProvider.metricsMetadata] + [languageProvider] ); // Builds a safe selector string from metric name and label values @@ -89,11 +89,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P const fetchMetrics = useCallback( async (safeSelector?: string) => { try { - const fetchedMetrics = await languageProvider.fetchSeriesValuesWithMatch( + const fetchedMetrics = await languageProvider.queryLabelValues( timeRangeRef.current, METRIC_LABEL, safeSelector, - 'MetricsBrowser_M', effectiveLimit ); return fetchedMetrics.map((m) => ({ @@ -113,13 +112,9 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P const fetchLabelKeys = useCallback( async (safeSelector?: string) => { try { - if (safeSelector) { - return Object.keys( - await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, safeSelector, effectiveLimit) - ); - } else { - return (await languageProvider.fetchLabels(timeRangeRef.current, undefined, effectiveLimit)) || []; - } + return ( + (await languageProvider.queryLabelKeys(timeRangeRef.current, safeSelector || undefined, effectiveLimit)) ?? [] + ); } catch (e) { handleError(e, 'Error fetching labels'); return []; @@ -135,17 +130,18 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P const newSelectedLabelValues: Record = {}; for (const lk of labelKeys) { try { - const values = await languageProvider.fetchSeriesValuesWithMatch( + const values = await languageProvider.queryLabelValues( timeRangeRef.current, lk, safeSelector, - `MetricsBrowser_LV_${lk}`, effectiveLimit ); transformedLabelValues[lk] = values; if (selectedLabelValues[lk]) { newSelectedLabelValues[lk] = [...selectedLabelValues[lk]]; } + + setErr(''); } catch (e) { handleError(e, 'Error fetching label values'); } @@ -294,11 +290,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P if (selectedLabelKeys.length !== 0) { for (const lk of selectedLabelKeys) { try { - const fetchedLabelValues = await languageProvider.fetchSeriesValuesWithMatch( + const fetchedLabelValues = await languageProvider.queryLabelValues( timeRange, lk, safeSelector, - `MetricsBrowser_LV_${lk}`, effectiveLimit ); @@ -314,6 +309,8 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P fetchedLabelValues.includes(item) ); } + + setErr(''); } catch (e: unknown) { handleError(e, 'Error fetching label values'); } @@ -352,7 +349,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P setErr(''); try { - const results = await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, selector, effectiveLimit); + const results = await languageProvider.queryLabelKeys(timeRangeRef.current, selector, effectiveLimit); setValidationStatus(`Selector is valid (${Object.keys(results).length} labels found)`); } catch (e) { handleError(e, 'Validation failed'); diff --git a/packages/grafana-prometheus/src/components/useMetricsState.test.ts b/packages/grafana-prometheus/src/components/useMetricsState.test.ts index 05808e35aea..c632935a287 100644 --- a/packages/grafana-prometheus/src/components/useMetricsState.test.ts +++ b/packages/grafana-prometheus/src/components/useMetricsState.test.ts @@ -8,7 +8,7 @@ import { useMetricsState } from './useMetricsState'; // Mock implementations const createMockLanguageProvider = (metrics: string[] = []): PrometheusLanguageProviderInterface => ({ - metrics, + retrieveMetrics: () => metrics, }) as unknown as PrometheusLanguageProviderInterface; const createMockDatasource = (lookupsDisabled = false): PrometheusDatasource => diff --git a/packages/grafana-prometheus/src/components/useMetricsState.ts b/packages/grafana-prometheus/src/components/useMetricsState.ts index 86ea0cced30..3ae3fb9aa6f 100644 --- a/packages/grafana-prometheus/src/components/useMetricsState.ts +++ b/packages/grafana-prometheus/src/components/useMetricsState.ts @@ -25,7 +25,7 @@ export function useMetricsState( syntaxLoaded: boolean ) { return useMemo(() => { - const hasMetrics = languageProvider.metrics.length > 0; + const hasMetrics = languageProvider.retrieveMetrics().length > 0; const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics); const buttonDisabled = !(syntaxLoaded && hasMetrics); @@ -34,5 +34,5 @@ export function useMetricsState( chooserText, buttonDisabled, }; - }, [languageProvider.metrics, datasource.lookupsDisabled, syntaxLoaded]); + }, [languageProvider, datasource.lookupsDisabled, syntaxLoaded]); } diff --git a/packages/grafana-prometheus/src/language_provider.mock.ts b/packages/grafana-prometheus/src/language_provider.mock.ts index 2ea779fad26..8c331ce01a0 100644 --- a/packages/grafana-prometheus/src/language_provider.mock.ts +++ b/packages/grafana-prometheus/src/language_provider.mock.ts @@ -1,12 +1,15 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/language_provider.mock.ts export class EmptyLanguageProviderMock { metrics = []; + constructor() {} + start() { return new Promise((resolve) => { resolve(''); }); } + getLabelKeys = jest.fn().mockReturnValue([]); getLabelValues = jest.fn().mockReturnValue([]); getSeries = jest.fn().mockReturnValue({ __name__: [] }); @@ -17,4 +20,5 @@ export class EmptyLanguageProviderMock { fetchLabelsWithMatch = jest.fn().mockReturnValue([]); fetchLabels = jest.fn(); loadMetricsMetadata = jest.fn(); + retrieveMetrics = jest.fn().mockReturnValue(['metric']); } diff --git a/packages/grafana-prometheus/src/resource_clients.test.ts b/packages/grafana-prometheus/src/resource_clients.test.ts index e0af51ff04e..0ab24bd9ff9 100644 --- a/packages/grafana-prometheus/src/resource_clients.test.ts +++ b/packages/grafana-prometheus/src/resource_clients.test.ts @@ -173,17 +173,18 @@ describe('SeriesApiClient', () => { }); describe('queryLabelKeys', () => { - it('should throw error if match parameter is not provided', async () => { - await expect(client.queryLabelKeys(mockTimeRange)).rejects.toThrow( - 'Series endpoint always expects at least one matcher' - ); - }); - - it('should fetch and process label keys from series', async () => { + it('should use MATCH_ALL_LABELS when no matcher is provided', async () => { mockRequest.mockResolvedValueOnce([{ __name__: 'metric1', label1: 'value1', label2: 'value2' }]); - const result = await client.queryLabelKeys(mockTimeRange, '{job="grafana"}'); + const result = await client.queryLabelKeys(mockTimeRange); + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/series', + expect.objectContaining({ + 'match[]': '{__name__!=""}', + }), + expect.any(Object) + ); expect(result).toEqual(['label1', 'label2']); }); @@ -201,6 +202,14 @@ describe('SeriesApiClient', () => { ); expect(result).toEqual(['label1', 'label2']); }); + + it('should fetch and process label keys from series', async () => { + mockRequest.mockResolvedValueOnce([{ __name__: 'metric1', label1: 'value1', label2: 'value2' }]); + + const result = await client.queryLabelKeys(mockTimeRange, '{job="grafana"}'); + + expect(result).toEqual(['label1', 'label2']); + }); }); describe('queryLabelValues', () => { @@ -215,7 +224,7 @@ describe('SeriesApiClient', () => { expect(mockRequest).toHaveBeenCalledWith( '/api/v1/series', expect.objectContaining({ - 'match[]': '{__name__="metric1"}', + 'match[]': '{__name__="metric1",job!=""}', }), expect.any(Object) ); @@ -249,6 +258,184 @@ describe('SeriesApiClient', () => { expect.any(Object) ); }); + + it('should use cache for subsequent identical queries', async () => { + // Setup mock response for first call + mockRequest.mockResolvedValueOnce([ + { __name__: 'metric1', job: 'grafana' }, + { __name__: 'metric2', job: 'prometheus' }, + ]); + + // First query - should hit the backend + const firstResult = await client.queryLabelValues(mockTimeRange, 'job', '{__name__="metric1"}'); + expect(firstResult).toEqual(['grafana', 'prometheus']); + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + '/api/v1/series', + expect.objectContaining({ + 'match[]': '{__name__="metric1",job!=""}', + }), + expect.any(Object) + ); + + // Reset mock to verify it's not called again + mockRequest.mockClear(); + + // Second query with same parameters - should use cache + const secondResult = await client.queryLabelValues(mockTimeRange, 'job', '{__name__="metric1"}'); + expect(secondResult).toEqual(['grafana', 'prometheus']); + expect(mockRequest).not.toHaveBeenCalled(); + }); + }); + + describe('SeriesCache', () => { + let cache: any; // Using any to access private members for testing + + beforeEach(() => { + jest.useFakeTimers(); + cache = (client as any)._seriesCache; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('cache key generation', () => { + it('should generate different cache keys for keys and values', () => { + const keyKey = cache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key'); + const valueKey = cache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'value'); + expect(keyKey).not.toEqual(valueKey); + }); + + it('should use cache level from constructor for time range snapping', () => { + const highLevelCache = new SeriesApiClient(mockRequest, { + cacheLevel: PrometheusCacheLevel.High, + getAdjustedInterval: mockGetAdjustedInterval, + getTimeRangeParams: mockGetTimeRangeParams, + } as unknown as PrometheusDatasource); + + const lowLevelCache = new SeriesApiClient(mockRequest, { + cacheLevel: PrometheusCacheLevel.Low, + getAdjustedInterval: mockGetAdjustedInterval, + getTimeRangeParams: mockGetTimeRangeParams, + } as unknown as PrometheusDatasource); + + const highKey = (highLevelCache as any)._seriesCache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key'); + const lowKey = (lowLevelCache as any)._seriesCache.getCacheKey(mockTimeRange, '{job="test"}', '1000', 'key'); + + expect(highKey).not.toEqual(lowKey); + }); + }); + + describe('cache size management', () => { + beforeEach(() => { + // Start with a clean cache for each test + cache._cache = {}; + cache._accessTimestamps = {}; + }); + + it('should remove oldest entries when max entries limit is reached', () => { + // Override MAX_CACHE_ENTRIES for testing + Object.defineProperty(cache, 'MAX_CACHE_ENTRIES', { value: 5 }); + + // Add entries up to the limit + cache.setLabelKeys(mockTimeRange, 'match1', '1000', ['key1']); + jest.advanceTimersByTime(1000); + cache.setLabelKeys(mockTimeRange, 'match2', '1000', ['key2']); + jest.advanceTimersByTime(1000); + cache.setLabelKeys(mockTimeRange, 'match3', '1000', ['key3']); + jest.advanceTimersByTime(1000); + cache.setLabelKeys(mockTimeRange, 'match4', '1000', ['key4']); + jest.advanceTimersByTime(1000); + cache.setLabelKeys(mockTimeRange, 'match5', '1000', ['key5']); + + // Access first entry to make it more recently used + cache.getLabelKeys(mockTimeRange, 'match1', '1000'); + + jest.advanceTimersByTime(1000); + + // Add sixth entry - this should trigger cache cleaning + // and remove 20% (1 entry) of the oldest entries + cache.setLabelKeys(mockTimeRange, 'match6', '1000', ['key6']); + + // Verify cache state - should have removed one entry (match2) + expect(Object.keys(cache._cache).length).toBe(5); + + // Second entry should be removed (was least recently used) + expect(cache.getLabelKeys(mockTimeRange, 'match2', '1000')).toBeUndefined(); + // First entry should exist (was accessed recently) + expect(cache.getLabelKeys(mockTimeRange, 'match1', '1000')).toEqual(['key1']); + // Third entry should exist + expect(cache.getLabelKeys(mockTimeRange, 'match3', '1000')).toEqual(['key3']); + // Fourth entry should exist + expect(cache.getLabelKeys(mockTimeRange, 'match4', '1000')).toEqual(['key4']); + // Fifth entry should exist + expect(cache.getLabelKeys(mockTimeRange, 'match5', '1000')).toEqual(['key5']); + // Sixth entry should exist (newest) + expect(cache.getLabelKeys(mockTimeRange, 'match6', '1000')).toEqual(['key6']); + }); + + it('should remove oldest entries when max size limit is reached', () => { + // Override MAX_CACHE_SIZE_BYTES for testing - set to small value to trigger cleanup + Object.defineProperty(cache, 'MAX_CACHE_SIZE_BYTES', { value: 10 }); // Very small size to force cleanup + + // Create entries that will exceed the size limit + const largeArray = Array(5).fill('large_value'); + + // Add first large entry + cache.setLabelKeys(mockTimeRange, 'match1', '1000', largeArray); + + // Verify initial size + expect(Object.keys(cache._cache).length).toBe(1); + expect(cache.getCacheSizeInBytes()).toBeGreaterThan(10); + + // Add second large entry - should trigger size-based cleanup + cache.setLabelKeys(mockTimeRange, 'match2', '1000', largeArray); + + // Verify cache state - should only have the newest entry + expect(Object.keys(cache._cache).length).toBe(1); + expect(cache.getLabelKeys(mockTimeRange, 'match1', '1000')).toBeUndefined(); + expect(cache.getLabelKeys(mockTimeRange, 'match2', '1000')).toEqual(largeArray); + + // Add third entry to verify the cleanup continues to work + cache.setLabelKeys(mockTimeRange, 'match3', '1000', largeArray); + expect(Object.keys(cache._cache).length).toBe(1); + expect(cache.getLabelKeys(mockTimeRange, 'match2', '1000')).toBeUndefined(); + expect(cache.getLabelKeys(mockTimeRange, 'match3', '1000')).toEqual(largeArray); + }); + + it('should update access time when getting cached values', () => { + // Add an entry + cache.setLabelKeys(mockTimeRange, 'match1', '1000', ['key1']); + const cacheKey = cache.getCacheKey(mockTimeRange, 'match1', '1000', 'key'); + const initialTimestamp = cache._accessTimestamps[cacheKey]; + + // Advance time + jest.advanceTimersByTime(1000); + + // Access the entry + cache.getLabelKeys(mockTimeRange, 'match1', '1000'); + const updatedTimestamp = cache._accessTimestamps[cacheKey]; + + // Verify timestamp was updated + expect(updatedTimestamp).toBeGreaterThan(initialTimestamp); + }); + }); + + describe('label values caching', () => { + it('should cache and retrieve label values', () => { + const values = ['value1', 'value2']; + cache.setLabelValues(mockTimeRange, '{job="test"}', '1000', values); + + const cachedValues = cache.getLabelValues(mockTimeRange, '{job="test"}', '1000'); + expect(cachedValues).toEqual(values); + }); + + it('should return undefined for non-existent label values', () => { + const result = cache.getLabelValues(mockTimeRange, '{job="nonexistent"}', '1000'); + expect(result).toBeUndefined(); + }); + }); }); }); diff --git a/packages/grafana-prometheus/src/resource_clients.ts b/packages/grafana-prometheus/src/resource_clients.ts index 0aa0db8584c..6335ca03d55 100644 --- a/packages/grafana-prometheus/src/resource_clients.ts +++ b/packages/grafana-prometheus/src/resource_clients.ts @@ -6,7 +6,8 @@ import { DEFAULT_SERIES_LIMIT } from './components/metrics-browser/types'; import { PrometheusDatasource } from './datasource'; import { removeQuotesIfExist } from './language_provider'; import { getRangeSnapInterval, processHistogramMetrics } from './language_utils'; -import { escapeForUtf8Support } from './utf8_support'; +import { PrometheusCacheLevel } from './types'; +import { escapeForUtf8Support, utf8Support } from './utf8_support'; type PrometheusSeriesResponse = Array<{ [key: string]: string }>; type PrometheusLabelsResponse = string[]; @@ -60,27 +61,6 @@ export abstract class BaseResourceClient { return Array.isArray(response) ? response : []; } - /** - * Validates and transforms a matcher string for Prometheus series queries. - * - * @param match - The matcher string to validate and transform. Can be undefined, a specific matcher, or '{}'. - * @returns The validated and potentially transformed matcher string. - * @throws Error if the matcher is undefined or empty (null, undefined, or empty string). - * - * @example - * // Returns '{__name__!=""}' for empty matcher - * validateAndTransformMatcher('{}') - * - * // Returns the original matcher for specific matchers - * validateAndTransformMatcher('{job="grafana"}') - */ - protected validateAndTransformMatcher(match?: string): string { - if (!match) { - throw new Error('Series endpoint always expects at least one matcher'); - } - return match === '{}' ? MATCH_ALL_LABELS : match; - } - /** * Fetches all time series that match a specific label matcher using **series** endpoint. * @@ -89,7 +69,7 @@ export abstract class BaseResourceClient { * @param {string} limit - Maximum number of series to return */ public querySeries = async (timeRange: TimeRange, match: string, limit: string = DEFAULT_SERIES_LIMIT) => { - const effectiveMatch = this.validateAndTransformMatcher(match); + const effectiveMatch = !match || match === EMPTY_MATCHER ? MATCH_ALL_LABELS : match; const timeParams = this.datasource.getTimeRangeParams(timeRange); const searchParams = { ...timeParams, 'match[]': effectiveMatch, limit }; return await this.requestSeries('/api/v1/series', searchParams, getDefaultCacheHeaders(this.datasource.cacheLevel)); @@ -166,6 +146,8 @@ export class LabelsApiClient extends BaseResourceClient implements ResourceApiCl } export class SeriesApiClient extends BaseResourceClient implements ResourceApiClient { + private _seriesCache: SeriesCache = new SeriesCache(this.datasource.cacheLevel); + public histogramMetrics: string[] = []; public metrics: string[] = []; public labelKeys: string[] = []; @@ -176,11 +158,13 @@ export class SeriesApiClient extends BaseResourceClient implements ResourceApiCl }; public queryMetrics = async (timeRange: TimeRange): Promise<{ metrics: string[]; histogramMetrics: string[] }> => { - const series = await this.querySeries(timeRange, MATCH_ALL_LABELS); - const { metrics, labelKeys } = processSeries(series); + const series = await this.querySeries(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT); + const { metrics, labelKeys } = processSeries(series, METRIC_LABEL); this.metrics = metrics; this.histogramMetrics = processHistogramMetrics(this.metrics); this.labelKeys = labelKeys; + this._seriesCache.setLabelValues(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT, metrics); + this._seriesCache.setLabelKeys(timeRange, MATCH_ALL_LABELS, DEFAULT_SERIES_LIMIT, labelKeys); return { metrics: this.metrics, histogramMetrics: this.histogramMetrics }; }; @@ -189,9 +173,15 @@ export class SeriesApiClient extends BaseResourceClient implements ResourceApiCl match?: string, limit: string = DEFAULT_SERIES_LIMIT ): Promise => { - const effectiveMatch = this.validateAndTransformMatcher(match); + const effectiveMatch = !match || match === EMPTY_MATCHER ? MATCH_ALL_LABELS : match; + const maybeCachedKeys = this._seriesCache.getLabelKeys(timeRange, effectiveMatch, limit); + if (maybeCachedKeys) { + return maybeCachedKeys; + } + const series = await this.querySeries(timeRange, effectiveMatch, limit); const { labelKeys } = processSeries(series); + this._seriesCache.setLabelKeys(timeRange, effectiveMatch, limit, labelKeys); return labelKeys; }; @@ -201,13 +191,123 @@ export class SeriesApiClient extends BaseResourceClient implements ResourceApiCl match?: string, limit: string = DEFAULT_SERIES_LIMIT ): Promise => { - const effectiveMatch = !match || match === EMPTY_MATCHER ? `{${labelKey}!=""}` : match; + const utf8SafeLabelKey = utf8Support(labelKey); + const effectiveMatch = + !match || match === EMPTY_MATCHER + ? `{${utf8SafeLabelKey}!=""}` + : match.slice(0, match.length - 1).concat(`,${utf8SafeLabelKey}!=""}`); + const maybeCachedValues = this._seriesCache.getLabelValues(timeRange, effectiveMatch, limit); + if (maybeCachedValues) { + return maybeCachedValues; + } + const series = await this.querySeries(timeRange, effectiveMatch, limit); const { labelValues } = processSeries(series, labelKey); + this._seriesCache.setLabelValues(timeRange, effectiveMatch, limit, labelValues); return labelValues; }; } +class SeriesCache { + private readonly MAX_CACHE_ENTRIES = 1000; // Maximum number of cache entries + private readonly MAX_CACHE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB max cache size + + private _cache: Record = {}; + private _accessTimestamps: Record = {}; + + constructor(private cacheLevel: PrometheusCacheLevel = PrometheusCacheLevel.High) {} + + public setLabelKeys(timeRange: TimeRange, match: string, limit: string, keys: string[]) { + // Check and potentially clean cache before adding new entry + this.cleanCacheIfNeeded(); + const cacheKey = this.getCacheKey(timeRange, match, limit, 'key'); + this._cache[cacheKey] = keys.slice().sort(); + this._accessTimestamps[cacheKey] = Date.now(); + } + + public getLabelKeys(timeRange: TimeRange, match: string, limit: string): string[] | undefined { + const cacheKey = this.getCacheKey(timeRange, match, limit, 'key'); + const result = this._cache[cacheKey]; + if (result) { + // Update access timestamp on cache hit + this._accessTimestamps[cacheKey] = Date.now(); + } + return result; + } + + public setLabelValues(timeRange: TimeRange, match: string, limit: string, values: string[]) { + // Check and potentially clean cache before adding new entry + this.cleanCacheIfNeeded(); + const cacheKey = this.getCacheKey(timeRange, match, limit, 'value'); + this._cache[cacheKey] = values.slice().sort(); + this._accessTimestamps[cacheKey] = Date.now(); + } + + public getLabelValues(timeRange: TimeRange, match: string, limit: string): string[] | undefined { + const cacheKey = this.getCacheKey(timeRange, match, limit, 'value'); + const result = this._cache[cacheKey]; + if (result) { + // Update access timestamp on cache hit + this._accessTimestamps[cacheKey] = Date.now(); + } + return result; + } + + private getCacheKey(timeRange: TimeRange, match: string, limit: string, type: 'key' | 'value') { + const snappedTimeRange = getRangeSnapInterval(this.cacheLevel, timeRange); + return [snappedTimeRange.start, snappedTimeRange.end, limit, match, type].join('|'); + } + + private cleanCacheIfNeeded() { + // Check number of entries + const currentEntries = Object.keys(this._cache).length; + if (currentEntries >= this.MAX_CACHE_ENTRIES) { + // Calculate 20% of current entries, but ensure we remove at least 1 entry + const entriesToRemove = Math.max(1, Math.floor(currentEntries - this.MAX_CACHE_ENTRIES + 1)); + this.removeOldestEntries(entriesToRemove); + } + + // Check cache size in bytes + const currentSize = this.getCacheSizeInBytes(); + if (currentSize > this.MAX_CACHE_SIZE_BYTES) { + // Calculate 20% of current entries, but ensure we remove at least 1 entry + const entriesToRemove = Math.max(1, Math.floor(Object.keys(this._cache).length * 0.2)); + this.removeOldestEntries(entriesToRemove); + } + } + + private getCacheSizeInBytes(): number { + let size = 0; + for (const key in this._cache) { + // Calculate size of key + size += key.length * 2; // Approximate size of string in bytes (UTF-16) + + // Calculate size of value array + const value = this._cache[key]; + for (const item of value) { + size += item.length * 2; // Approximate size of each string in bytes + } + } + return size; + } + + private removeOldestEntries(count: number) { + // Get all entries sorted by timestamp (oldest first) + const entries = Object.entries(this._accessTimestamps).sort( + ([, timestamp1], [, timestamp2]) => timestamp1 - timestamp2 + ); + + // Take the oldest 'count' entries + const entriesToRemove = entries.slice(0, count); + + // Remove these entries from both cache and timestamps + for (const [key] of entriesToRemove) { + delete this._cache[key]; + delete this._accessTimestamps[key]; + } + } +} + export function processSeries(series: Array<{ [key: string]: string }>, findValuesForKey?: string) { const metrics: Set = new Set(); const labelKeys: Set = new Set();