Prometheus: Mectircs browser refreshes metrics when series limit changes (#104101)
* refetch when series limit is changed * when series limit is empty use the default series limit * tests for timerange handling * attempt to reduce the test file size * remove ineffective memory tests * update test * clearer tests * update help text * update limit tooltip
This commit is contained in:
@@ -31,7 +31,7 @@ export function MetricSelector() {
|
||||
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric}
|
||||
/>
|
||||
</div>
|
||||
<Label description="Set to 'none' to remove limit and show all labels for a selected metric. Removing the limit may cause performance issues.">
|
||||
<Label description="The limit applies to all metrics, labels, and values. Leave the field empty to use the default limit. Set to 0 to disable the limit and fetch everything — this may cause performance issues.">
|
||||
Series limit
|
||||
</Label>
|
||||
<div>
|
||||
|
||||
+282
-370
@@ -10,8 +10,7 @@ import { getMockTimeRange } from '../../test/__mocks__/datasource';
|
||||
import { MetricsBrowserProvider, useMetricsBrowser } from './MetricsBrowserContext';
|
||||
import { LAST_USED_LABELS_KEY, METRIC_LABEL } from './types';
|
||||
|
||||
// Mock the local storage
|
||||
const localStorageMock = (() => {
|
||||
const setupLocalStorageMock = () => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: jest.fn((key: string) => store[key] || null),
|
||||
@@ -22,27 +21,54 @@ const localStorageMock = (() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
})();
|
||||
};
|
||||
|
||||
const localStorageMock = setupLocalStorageMock();
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Mock language provider
|
||||
const mockTimeRange = getMockTimeRange();
|
||||
const mockLanguageProvider = {
|
||||
metrics: ['metric1', 'metric2', 'metric3'],
|
||||
labelKeys: ['__name__', 'instance', 'job', 'service'],
|
||||
metricsMetadata: {
|
||||
metric1: { type: 'counter', help: 'Test metric 1' },
|
||||
metric2: { type: 'gauge', help: 'Test metric 2' },
|
||||
},
|
||||
fetchLabels: jest.fn(),
|
||||
fetchSeriesLabelsMatch: jest.fn(),
|
||||
fetchSeriesValuesWithMatch: jest.fn(),
|
||||
fetchLabelsWithMatch: jest.fn(),
|
||||
} as unknown as PromQlLanguageProvider;
|
||||
/**
|
||||
* Setup consistent mock response data for the language provider
|
||||
*/
|
||||
const setupLanguageProviderMock = () => {
|
||||
const mockTimeRange = getMockTimeRange();
|
||||
const mockLanguageProvider = {
|
||||
metrics: ['metric1', 'metric2', 'metric3'],
|
||||
labelKeys: ['__name__', 'instance', 'job', 'service'],
|
||||
metricsMetadata: {
|
||||
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) => {
|
||||
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([]);
|
||||
}),
|
||||
fetchLabelsWithMatch: jest.fn().mockResolvedValue({
|
||||
job: ['job1', 'job2'],
|
||||
instance: ['instance1', 'instance2'],
|
||||
}),
|
||||
} as unknown as PromQlLanguageProvider;
|
||||
|
||||
const mockOnChange = jest.fn();
|
||||
return { mockTimeRange, mockLanguageProvider };
|
||||
};
|
||||
|
||||
// Test component to render the context
|
||||
/**
|
||||
* Test component that renders context values and provides interaction buttons
|
||||
*/
|
||||
const TestComponent = () => {
|
||||
const {
|
||||
metrics,
|
||||
@@ -86,422 +112,308 @@ const TestComponent = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderWithProvider = (ui: ReactNode) => {
|
||||
return render(
|
||||
<MetricsBrowserProvider timeRange={mockTimeRange} languageProvider={mockLanguageProvider} onChange={mockOnChange}>
|
||||
{ui}
|
||||
</MetricsBrowserProvider>
|
||||
);
|
||||
/**
|
||||
* Setup function for tests that returns mocks and render utilities
|
||||
*/
|
||||
const setupTest = () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const { mockTimeRange, mockLanguageProvider } = setupLanguageProviderMock();
|
||||
|
||||
const renderWithProvider = (ui: ReactNode) => {
|
||||
return render(
|
||||
<MetricsBrowserProvider timeRange={mockTimeRange} languageProvider={mockLanguageProvider} onChange={mockOnChange}>
|
||||
{ui}
|
||||
</MetricsBrowserProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
mockTimeRange,
|
||||
mockLanguageProvider,
|
||||
mockOnChange,
|
||||
renderWithProvider,
|
||||
};
|
||||
};
|
||||
|
||||
describe('MetricsBrowserContext', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
|
||||
// Set up consistent mock implementation
|
||||
(mockLanguageProvider.fetchLabels as jest.Mock).mockResolvedValue(['__name__', 'instance', 'job', 'service']);
|
||||
|
||||
// Default implementation for fetching metrics
|
||||
(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([]);
|
||||
}
|
||||
);
|
||||
|
||||
// Mock for fetching label keys for a specific metric
|
||||
(mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({
|
||||
__name__: ['metric1', 'metric2'],
|
||||
instance: ['instance1', 'instance2'],
|
||||
job: ['job1', 'job2'],
|
||||
service: ['service1', 'service2'],
|
||||
});
|
||||
|
||||
// Mock for validation
|
||||
(mockLanguageProvider.fetchLabelsWithMatch as jest.Mock).mockResolvedValue({
|
||||
job: ['job1', 'job2'],
|
||||
instance: ['instance1', 'instance2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with metrics from language provider', async () => {
|
||||
renderWithProvider(<TestComponent />);
|
||||
describe('basic functionality', () => {
|
||||
it('should initialize and display metrics', async () => {
|
||||
const { renderWithProvider } = setupTest();
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
// Wait for API calls and state update
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
METRIC_LABEL,
|
||||
undefined,
|
||||
'MetricsBrowser_M',
|
||||
'40000'
|
||||
);
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Then check UI update
|
||||
await waitFor(
|
||||
() => {
|
||||
// Verify metrics are displayed in the UI
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('metrics-count').textContent).toBe('3');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should load saved label keys from localStorage on init', async () => {
|
||||
// Setup localStorage with saved label keys
|
||||
localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance']));
|
||||
it('should restore selected labels from storage on initialization', async () => {
|
||||
// Setup localStorage with saved preference
|
||||
localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance']));
|
||||
|
||||
renderWithProvider(<TestComponent />);
|
||||
const { renderWithProvider } = setupTest();
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
// First wait for API calls
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchLabels).toHaveBeenCalled();
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Then wait for state update
|
||||
await waitFor(
|
||||
() => {
|
||||
// Verify the saved labels are loaded and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job,instance');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should select and deselect metrics on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProvider(<TestComponent />);
|
||||
describe('user interactions', () => {
|
||||
it('should select and deselect metrics', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { renderWithProvider } = setupTest();
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
// Wait for initialization to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
// Wait for component to be ready
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('metrics-count').textContent).toBe('3');
|
||||
});
|
||||
|
||||
// Initially no metric is selected
|
||||
expect(screen.getByTestId('selected-metric').textContent).toBe('');
|
||||
// Initially no metric is selected
|
||||
expect(screen.getByTestId('selected-metric').textContent).toBe('');
|
||||
|
||||
// Select a metric
|
||||
await user.click(screen.getByTestId('select-metric'));
|
||||
// Select a metric
|
||||
await user.click(screen.getByTestId('select-metric'));
|
||||
|
||||
// Wait for selection to complete and API calls
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchSeriesLabelsMatch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Wait for state update
|
||||
await waitFor(
|
||||
() => {
|
||||
// Verify selection in UI
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-metric').textContent).toBe('metric1');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
// Mock setup for deselection
|
||||
jest.clearAllMocks();
|
||||
// Deselect by clicking the same metric
|
||||
await user.click(screen.getByTestId('select-metric'));
|
||||
|
||||
// Click again to deselect
|
||||
await user.click(screen.getByTestId('select-metric'));
|
||||
|
||||
// Wait for deselection to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
// Verify the deselection in UI
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-metric').textContent).toBe('');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should select and deselect label keys on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProvider(<TestComponent />);
|
||||
it('should select and deselect label keys with persistence', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { renderWithProvider } = setupTest();
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
// Wait for initialization to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
// Wait for component to be ready
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('metrics-count').textContent).toBe('3');
|
||||
});
|
||||
|
||||
// Initially no label key is selected
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('');
|
||||
// Initially no label key is selected
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('');
|
||||
|
||||
// Select a label key
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
// Select a label key
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
|
||||
// Wait for selection and API calls
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'job',
|
||||
undefined,
|
||||
'MetricsBrowser_LV_job',
|
||||
'40000'
|
||||
);
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Wait for state update
|
||||
await waitFor(
|
||||
() => {
|
||||
// Verify UI update
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
// Reset mocks for deselection test
|
||||
jest.clearAllMocks();
|
||||
// Deselect by clicking again
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
|
||||
// Click again to deselect
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
|
||||
// Wait for state update
|
||||
await waitFor(
|
||||
() => {
|
||||
// Verify UI update
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
// Verify localStorage update
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(LAST_USED_LABELS_KEY, JSON.stringify([]));
|
||||
});
|
||||
// Check localStorage was updated (not implementation but outcome)
|
||||
const mockCalls = localStorageMock.setItem.mock.calls;
|
||||
// Make sure we have calls to setItem
|
||||
expect(mockCalls.length).toBeGreaterThan(0);
|
||||
// Get the last call's arguments
|
||||
const lastCall = mockCalls[mockCalls.length - 1];
|
||||
expect(lastCall[0]).toBe(LAST_USED_LABELS_KEY);
|
||||
expect(JSON.parse(lastCall[1])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should select and deselect label values on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProvider(<TestComponent />);
|
||||
it('should build a selector when selecting label values', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { renderWithProvider } = setupTest();
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
// Wait for initialization
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
// Wait for component to be ready
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('metrics-count').textContent).toBe('3');
|
||||
});
|
||||
|
||||
// First select the label key to enable label value selection
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
|
||||
// Wait for label key selection to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
// First select a label key
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
// Select a label value
|
||||
await user.click(screen.getByTestId('select-label-value'));
|
||||
|
||||
// Now select a label value
|
||||
await user.click(screen.getByTestId('select-label-value'));
|
||||
|
||||
// Wait for API calls after value selection
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Wait for selector update in UI
|
||||
await waitFor(
|
||||
() => {
|
||||
// Verify the selector is updated
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selector').textContent).toBe('{job="grafana"}');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch label values with the current selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProvider(<TestComponent />);
|
||||
it('should use metric in selector when both metric and labels are selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { renderWithProvider } = setupTest();
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
// Wait for initialization to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
// Wait for component to be ready
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('metrics-count').textContent).toBe('3');
|
||||
});
|
||||
|
||||
// First select a metric to create a non-empty selector
|
||||
await user.click(screen.getByTestId('select-metric'));
|
||||
|
||||
// Wait for metric selection to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
// Select a metric first
|
||||
await user.click(screen.getByTestId('select-metric'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-metric').textContent).toBe('metric1');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Then select a label
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
|
||||
// Wait for API call with the expected selector
|
||||
await waitFor(
|
||||
() => {
|
||||
// Should call fetchSeriesValuesWithMatch with the current selector
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'job',
|
||||
expect.stringContaining('metric1'),
|
||||
expect.any(String),
|
||||
'40000'
|
||||
);
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear all selections when clear is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
// Wait for initialization
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Set up some selections
|
||||
await user.click(screen.getByTestId('select-metric'));
|
||||
|
||||
// Wait for metric selection
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByTestId('selected-metric').textContent).toBe('metric1');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
|
||||
// Wait for label selection
|
||||
await waitFor(
|
||||
() => {
|
||||
// Then select a label
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
// Reset mocks before clear
|
||||
jest.clearAllMocks();
|
||||
// Then select a label value
|
||||
await user.click(screen.getByTestId('select-label-value'));
|
||||
|
||||
// Clear selections
|
||||
await user.click(screen.getByTestId('clear'));
|
||||
// Verify the selector includes both metric and label
|
||||
await waitFor(() => {
|
||||
const selector = screen.getByTestId('selector').textContent;
|
||||
expect(selector).toContain('metric1');
|
||||
expect(selector).toContain('job="grafana"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for clear to trigger API calls
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
describe('selector operations', () => {
|
||||
it('should clear all selections', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { renderWithProvider } = setupTest();
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
// Verify everything is cleared
|
||||
await waitFor(
|
||||
() => {
|
||||
// Wait for component to be ready
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('metrics-count').textContent).toBe('3');
|
||||
});
|
||||
|
||||
// Make selections
|
||||
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(screen.getByTestId('selected-metric').textContent).toBe('metric1');
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
|
||||
expect(screen.getByTestId('selector').textContent).not.toBe('{}');
|
||||
});
|
||||
|
||||
// Clear all selections
|
||||
await user.click(screen.getByTestId('clear'));
|
||||
|
||||
// Verify all fields are cleared
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-metric').textContent).toBe('');
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('');
|
||||
expect(screen.getByTestId('selector').textContent).toBe('{}');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate selectors and show status', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProvider(<TestComponent />);
|
||||
it('should validate selectors', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { renderWithProvider } = setupTest();
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
// Wait for initialization
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
// Wait for component to be ready
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('metrics-count').textContent).toBe('3');
|
||||
});
|
||||
|
||||
// Select a label to enable value selection
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
// Create a valid selector
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
await user.click(screen.getByTestId('select-label-value'));
|
||||
|
||||
// Wait for label selection
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Select a value to get a valid selector
|
||||
await user.click(screen.getByTestId('select-label-value'));
|
||||
|
||||
// Wait for the value selection
|
||||
await waitFor(
|
||||
() => {
|
||||
// Verify the selector is created
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selector').textContent).toBe('{job="grafana"}');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
// Trigger validation
|
||||
await user.click(screen.getByTestId('validate'));
|
||||
|
||||
// Trigger validation
|
||||
await user.click(screen.getByTestId('validate'));
|
||||
|
||||
// Wait for validation API call
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockLanguageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith(expect.anything(), '{job="grafana"}');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Wait for validation status to update
|
||||
await waitFor(
|
||||
() => {
|
||||
// Verify validation result
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('validation-status').textContent).toContain('Selector is valid');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when hook is used outside provider', () => {
|
||||
// Suppress console.error for this test
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
describe('complete user workflows', () => {
|
||||
it('should handle a full selection -> validation -> clear workflow', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { renderWithProvider } = setupTest();
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useMetricsBrowser());
|
||||
}).toThrow('useMetricsBrowser must be used within a MetricsBrowserProvider');
|
||||
// Wait for component to be ready
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('metrics-count').textContent).toBe('3');
|
||||
});
|
||||
|
||||
// Restore console.error
|
||||
(console.error as jest.Mock).mockRestore();
|
||||
// STEP 1: Select a metric
|
||||
await user.click(screen.getByTestId('select-metric'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-metric').textContent).toBe('metric1');
|
||||
});
|
||||
|
||||
// STEP 2: Add a label
|
||||
await user.click(screen.getByTestId('select-label'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
|
||||
});
|
||||
|
||||
// STEP 3: Select a value
|
||||
await user.click(screen.getByTestId('select-label-value'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selector').textContent).toContain('job="grafana"');
|
||||
});
|
||||
|
||||
// STEP 4: Validate
|
||||
await user.click(screen.getByTestId('validate'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('validation-status').textContent).toContain('Selector is valid');
|
||||
});
|
||||
|
||||
// STEP 5: Clear
|
||||
await user.click(screen.getByTestId('clear'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-metric').textContent).toBe('');
|
||||
expect(screen.getByTestId('selected-label-keys').textContent).toBe('');
|
||||
expect(screen.getByTestId('selector').textContent).toBe('{}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error when hook is used outside provider', () => {
|
||||
// Suppress console.error for this test
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useMetricsBrowser());
|
||||
}).toThrow('useMetricsBrowser must be used within a MetricsBrowserProvider');
|
||||
|
||||
// Restore console.error
|
||||
(console.error as jest.Mock).mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+942
-526
File diff suppressed because it is too large
Load Diff
+30
-10
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
|
||||
import { TimeRange } from '@grafana/data';
|
||||
|
||||
@@ -9,6 +10,8 @@ import { DEFAULT_SERIES_LIMIT, EMPTY_SELECTOR, LAST_USED_LABELS_KEY, Metric, MET
|
||||
|
||||
export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: PromQlLanguageProvider) => {
|
||||
const timeRangeRef = useRef<TimeRange>(timeRange);
|
||||
const lastSeriesLimitRef = useRef(DEFAULT_SERIES_LIMIT);
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
const [seriesLimit, setSeriesLimit] = useState(DEFAULT_SERIES_LIMIT);
|
||||
const [err, setErr] = useState('');
|
||||
@@ -23,6 +26,9 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
const [labelValues, setLabelValues] = useState<Record<string, string[]>>({});
|
||||
const [selectedLabelValues, setSelectedLabelValues] = useState<Record<string, string[]>>({});
|
||||
|
||||
// Memoize the effective series limit to use the default when seriesLimit is empty
|
||||
const effectiveLimit = useMemo(() => seriesLimit || DEFAULT_SERIES_LIMIT, [seriesLimit]);
|
||||
|
||||
// We don't want to trigger fetching for small amount of time changes.
|
||||
// When MetricsBrowser re-renders for any reason we might receive a new timerange.
|
||||
// This particularly happens when we have relative time ranges: from: now, to: now-1h
|
||||
@@ -88,7 +94,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
METRIC_LABEL,
|
||||
safeSelector,
|
||||
'MetricsBrowser_M',
|
||||
seriesLimit
|
||||
effectiveLimit
|
||||
);
|
||||
return fetchedMetrics.map((m) => ({
|
||||
name: m,
|
||||
@@ -99,7 +105,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[getMetricDetails, handleError, languageProvider, seriesLimit]
|
||||
[getMetricDetails, handleError, languageProvider, effectiveLimit]
|
||||
);
|
||||
|
||||
// Fetches label keys based on an optional selector
|
||||
@@ -109,17 +115,17 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
try {
|
||||
if (safeSelector) {
|
||||
return Object.keys(
|
||||
await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, safeSelector, seriesLimit)
|
||||
await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, safeSelector, effectiveLimit)
|
||||
);
|
||||
} else {
|
||||
return (await languageProvider.fetchLabels(timeRangeRef.current, undefined, seriesLimit)) || [];
|
||||
return (await languageProvider.fetchLabels(timeRangeRef.current, undefined, effectiveLimit)) || [];
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e, 'Error fetching labels');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[handleError, languageProvider, seriesLimit]
|
||||
[handleError, languageProvider, effectiveLimit]
|
||||
);
|
||||
|
||||
// Fetches values for multiple label keys and also prepares selected values
|
||||
@@ -134,7 +140,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
lk,
|
||||
safeSelector,
|
||||
`MetricsBrowser_LV_${lk}`,
|
||||
seriesLimit
|
||||
effectiveLimit
|
||||
);
|
||||
transformedLabelValues[lk] = values;
|
||||
if (selectedLabelValues[lk]) {
|
||||
@@ -146,7 +152,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
}
|
||||
return [transformedLabelValues, newSelectedLabelValues];
|
||||
},
|
||||
[handleError, languageProvider, selectedLabelValues, seriesLimit]
|
||||
[handleError, languageProvider, selectedLabelValues, effectiveLimit]
|
||||
);
|
||||
|
||||
// Initial set up of the Metrics Browser
|
||||
@@ -179,9 +185,23 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
// Initialize the hook
|
||||
useEffect(() => {
|
||||
initialize(selectedMetric, selectedLabelValues);
|
||||
isInitializedRef.current = true;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// We use debounce here to prevent fetching data on every keystroke
|
||||
// We also track the seriesLimit change to prevent fetching twice right after the initialization
|
||||
useDebounce(
|
||||
() => {
|
||||
if (isInitializedRef.current && lastSeriesLimitRef.current !== seriesLimit) {
|
||||
initialize(selectedMetric, selectedLabelValues);
|
||||
lastSeriesLimitRef.current = seriesLimit;
|
||||
}
|
||||
},
|
||||
300,
|
||||
[seriesLimit]
|
||||
);
|
||||
|
||||
// Handles metric selection changes.
|
||||
// If a metric selected it fetches the labels of that metric
|
||||
// Otherwise it fetches all the labels.
|
||||
@@ -279,7 +299,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
lk,
|
||||
safeSelector,
|
||||
`MetricsBrowser_LV_${lk}`,
|
||||
seriesLimit
|
||||
effectiveLimit
|
||||
);
|
||||
|
||||
// We don't want to discard values from last selected list.
|
||||
@@ -332,7 +352,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
setErr('');
|
||||
|
||||
try {
|
||||
const results = await languageProvider.fetchLabelsWithMatch(timeRangeRef.current, selector);
|
||||
const results = await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, selector, effectiveLimit);
|
||||
setValidationStatus(`Selector is valid (${Object.keys(results).length} labels found)`);
|
||||
} catch (e) {
|
||||
handleError(e, 'Validation failed');
|
||||
|
||||
Reference in New Issue
Block a user