mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-24 21:30:26 +00:00
Compare commits
1 Commits
fix/query-
...
fix/hosts-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5df66e72b4 |
@@ -13,7 +13,9 @@ export interface HostListPayload {
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
} | null;
|
||||
start?: number;
|
||||
end?: number;
|
||||
}
|
||||
|
||||
export interface TimeSeriesValue {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
|
||||
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { createCustomTimeRange } from '../../store/globalTime/utils';
|
||||
|
||||
/**
|
||||
* Adapter component that syncs Redux global time state to Zustand store.
|
||||
* This component should be rendered once at the app level.
|
||||
*
|
||||
* It reads from the Redux globalTime reducer and updates the Zustand store
|
||||
* to provide a migration path from Redux to Zustand.
|
||||
*/
|
||||
export function GlobalTimeStoreAdapter(): null {
|
||||
const globalTime = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const setSelectedTime = useGlobalTimeStore((s) => s.setSelectedTime);
|
||||
|
||||
useEffect(() => {
|
||||
// Convert the selectedTime to the new format
|
||||
// If it's 'custom', store the min/max times in the custom format
|
||||
const selectedTime =
|
||||
globalTime.selectedTime === 'custom'
|
||||
? createCustomTimeRange(globalTime.minTime, globalTime.maxTime)
|
||||
: globalTime.selectedTime;
|
||||
|
||||
// Find refresh interval from Redux state
|
||||
const refreshOption = refreshIntervalOptions.find(
|
||||
(option) => option.key === globalTime.selectedAutoRefreshInterval,
|
||||
);
|
||||
|
||||
const refreshInterval =
|
||||
!globalTime.isAutoRefreshDisabled && refreshOption ? refreshOption.value : 0;
|
||||
|
||||
setSelectedTime(selectedTime, refreshInterval);
|
||||
}, [
|
||||
globalTime.selectedTime,
|
||||
globalTime.isAutoRefreshDisabled,
|
||||
globalTime.selectedAutoRefreshInterval,
|
||||
globalTime.minTime,
|
||||
globalTime.maxTime,
|
||||
setSelectedTime,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { act, render, renderHook } from '@testing-library/react';
|
||||
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
|
||||
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
|
||||
import { createCustomTimeRange } from 'store/globalTime/utils';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { GlobalTimeStoreAdapter } from '../GlobalTimeStoreAdapter';
|
||||
|
||||
const mockStore = configureStore<Partial<AppState>>([]);
|
||||
|
||||
describe('GlobalTimeStoreAdapter', () => {
|
||||
let store: MockStoreEnhanced<Partial<AppState>>;
|
||||
|
||||
const createGlobalTimeState = (
|
||||
overrides: Partial<GlobalReducer> = {},
|
||||
): GlobalReducer => ({
|
||||
minTime: 1700000000000000000,
|
||||
maxTime: 1700000001000000000,
|
||||
loading: false,
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: true,
|
||||
selectedAutoRefreshInterval: 'off',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset Zustand store before each test
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
act(() => {
|
||||
result.current.setSelectedTime('30s', 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render null because it just an adapter', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState(),
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should sync relative time from Redux to Zustand store', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: true,
|
||||
selectedAutoRefreshInterval: 'off',
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
expect(result.current.refreshInterval).toBe(0);
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should sync custom time from Redux to Zustand store', () => {
|
||||
const minTime = 1700000000000000000;
|
||||
const maxTime = 1700000001000000000;
|
||||
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: 'custom',
|
||||
minTime,
|
||||
maxTime,
|
||||
isAutoRefreshDisabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe(
|
||||
createCustomTimeRange(minTime, maxTime),
|
||||
);
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should sync refresh interval when auto refresh is enabled', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '5s',
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
expect(result.current.refreshInterval).toBe(5000); // 5s = 5000ms
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should set refreshInterval to 0 when auto refresh is disabled', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: true,
|
||||
selectedAutoRefreshInterval: '5s', // Even with interval set, should be 0 when disabled
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.refreshInterval).toBe(0);
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should update Zustand store when Redux state changes', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Verify initial state
|
||||
let zustandState = renderHook(() => useGlobalTimeStore());
|
||||
expect(zustandState.result.current.selectedTime).toBe('15m');
|
||||
|
||||
// Update Redux store
|
||||
const newStore = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '1h',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '30s',
|
||||
}),
|
||||
});
|
||||
|
||||
rerender(
|
||||
<Provider store={newStore}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Verify updated state
|
||||
zustandState = renderHook(() => useGlobalTimeStore());
|
||||
expect(zustandState.result.current.selectedTime).toBe('1h');
|
||||
expect(zustandState.result.current.refreshInterval).toBe(30000); // 30s = 30000ms
|
||||
expect(zustandState.result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle various refresh interval options', () => {
|
||||
const testCases = [
|
||||
{ key: '5s', expectedValue: 5000 },
|
||||
{ key: '10s', expectedValue: 10000 },
|
||||
{ key: '30s', expectedValue: 30000 },
|
||||
{ key: '1m', expectedValue: 60000 },
|
||||
{ key: '5m', expectedValue: 300000 },
|
||||
];
|
||||
|
||||
testCases.forEach(({ key, expectedValue }) => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: key,
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.refreshInterval).toBe(expectedValue);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown refresh interval by setting 0', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: 'unknown-interval',
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.refreshInterval).toBe(0);
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,11 @@
|
||||
export const REACT_QUERY_KEY = {
|
||||
/**
|
||||
* For any query that should support AutoRefresh and min/max time is from DateTimeSelectionV2
|
||||
* You can prefix the query with this KEY, it will allow the queries to be automatically refreshed
|
||||
* when the user clicks in the refresh button, or alert the user when the data is being refreshed.
|
||||
*/
|
||||
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
|
||||
|
||||
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
|
||||
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
|
||||
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { VerticalAlignTopOutlined } from '@ant-design/icons';
|
||||
import { Button, Tooltip, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { HostListPayload } from 'api/infraMonitoring/getHostLists';
|
||||
import {
|
||||
getHostLists,
|
||||
HostListPayload,
|
||||
HostListResponse,
|
||||
} from 'api/infraMonitoring/getHostLists';
|
||||
import HostMetricDetail from 'components/HostMetricsDetail';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
@@ -16,40 +19,40 @@ import {
|
||||
} from 'container/InfraMonitoringK8s/commonUtils';
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from 'container/InfraMonitoringK8s/constants';
|
||||
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
|
||||
import { useGetHostList } from 'hooks/infraMonitoring/useGetHostList';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { Filter } from 'lucide-react';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { FeatureKeys } from '../../constants/features';
|
||||
import { REACT_QUERY_KEY } from '../../constants/reactQueryKeys';
|
||||
import { useAppContext } from '../../providers/App/App';
|
||||
import HostsListControls from './HostsListControls';
|
||||
import HostsListTable from './HostsListTable';
|
||||
import { getHostListsQuery, GetHostsQuickFiltersConfig } from './utils';
|
||||
|
||||
import './InfraMonitoring.styles.scss';
|
||||
|
||||
const defaultFilters: TagFilter = { items: [], op: 'and' };
|
||||
const baseQuery = getHostListsQuery();
|
||||
|
||||
function HostsList(): JSX.Element {
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
|
||||
const filters = getFiltersFromParams(
|
||||
const filtersFromParams = getFiltersFromParams(
|
||||
searchParams,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.FILTERS,
|
||||
);
|
||||
if (!filters) {
|
||||
return {
|
||||
items: [],
|
||||
op: 'and',
|
||||
};
|
||||
}
|
||||
return filters;
|
||||
return filtersFromParams ?? defaultFilters;
|
||||
});
|
||||
const [showFilters, setShowFilters] = useState<boolean>(true);
|
||||
|
||||
@@ -83,57 +86,48 @@ function HostsList(): JSX.Element {
|
||||
|
||||
const { pageSize, setPageSize } = usePageSize('hosts');
|
||||
|
||||
const query = useMemo(() => {
|
||||
const baseQuery = getHostListsQuery();
|
||||
return {
|
||||
...baseQuery,
|
||||
limit: pageSize,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
filters,
|
||||
start: Math.floor(minTime / 1000000),
|
||||
end: Math.floor(maxTime / 1000000),
|
||||
orderBy,
|
||||
};
|
||||
}, [pageSize, currentPage, filters, minTime, maxTime, orderBy]);
|
||||
const selectedTime = useGlobalTimeStore((store) => store.selectedTime);
|
||||
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
|
||||
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
if (selectedHostName) {
|
||||
return [
|
||||
'hostList',
|
||||
String(pageSize),
|
||||
String(currentPage),
|
||||
JSON.stringify(filters),
|
||||
JSON.stringify(orderBy),
|
||||
];
|
||||
}
|
||||
return [
|
||||
'hostList',
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
REACT_QUERY_KEY.GET_HOST_LIST,
|
||||
String(pageSize),
|
||||
String(currentPage),
|
||||
JSON.stringify(filters),
|
||||
JSON.stringify(orderBy),
|
||||
String(minTime),
|
||||
String(maxTime),
|
||||
];
|
||||
}, [
|
||||
pageSize,
|
||||
currentPage,
|
||||
filters,
|
||||
orderBy,
|
||||
selectedHostName,
|
||||
minTime,
|
||||
maxTime,
|
||||
]);
|
||||
|
||||
const { data, isFetching, isLoading, isError } = useGetHostList(
|
||||
query as HostListPayload,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!query,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
selectedTime,
|
||||
],
|
||||
[pageSize, currentPage, filters, orderBy, selectedTime],
|
||||
);
|
||||
|
||||
const { data, isFetching, isLoading, isError } = useQuery<
|
||||
SuccessResponse<HostListResponse> | ErrorResponse,
|
||||
Error
|
||||
>({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) => {
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
|
||||
const payload: HostListPayload = {
|
||||
...baseQuery,
|
||||
limit: pageSize,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
filters: filters ?? defaultFilters,
|
||||
orderBy,
|
||||
start: Math.floor(minTime / 1000000),
|
||||
end: Math.floor(maxTime / 1000000),
|
||||
};
|
||||
|
||||
return getHostLists(payload, signal);
|
||||
},
|
||||
enabled: true,
|
||||
refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
});
|
||||
|
||||
const hostMetricsData = useMemo(() => data?.payload?.data?.records || [], [
|
||||
data,
|
||||
]);
|
||||
@@ -253,7 +247,7 @@ function HostsList(): JSX.Element {
|
||||
isError={isError}
|
||||
tableData={data}
|
||||
hostMetricsData={hostMetricsData}
|
||||
filters={filters || { items: [], op: 'AND' }}
|
||||
filters={filters ?? defaultFilters}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
onHostClick={handleHostClick}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render } from '@testing-library/react';
|
||||
import * as useGetHostListHooks from 'hooks/infraMonitoring/useGetHostList';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import * as getHostListsApi from 'api/infraMonitoring/getHostLists';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import * as timezoneHooks from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
@@ -18,6 +18,10 @@ jest.mock('lib/getMinMax', () => ({
|
||||
maxTime: 1713738000000,
|
||||
isValidShortHandDateTimeFormat: jest.fn().mockReturnValue(true),
|
||||
})),
|
||||
getMinMaxForSelectedTime: jest.fn().mockReturnValue({
|
||||
minTime: 1713734400000000000,
|
||||
maxTime: 1713738000000000000,
|
||||
}),
|
||||
}));
|
||||
jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
|
||||
__esModule: true,
|
||||
@@ -30,7 +34,13 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
@@ -82,27 +92,40 @@ jest.spyOn(timezoneHooks, 'useTimezone').mockReturnValue({
|
||||
offset: 0,
|
||||
},
|
||||
} as any);
|
||||
jest.spyOn(useGetHostListHooks, 'useGetHostList').mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
records: [
|
||||
{
|
||||
hostName: 'test-host',
|
||||
active: true,
|
||||
cpu: 0.75,
|
||||
memory: 0.65,
|
||||
wait: 0.03,
|
||||
},
|
||||
],
|
||||
isSendingK8SAgentMetrics: false,
|
||||
sentAnyHostMetricsData: true,
|
||||
},
|
||||
|
||||
jest.spyOn(getHostListsApi, 'getHostLists').mockResolvedValue({
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'list',
|
||||
records: [
|
||||
{
|
||||
hostName: 'test-host',
|
||||
active: true,
|
||||
os: 'linux',
|
||||
cpu: 0.75,
|
||||
cpuTimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
memory: 0.65,
|
||||
memoryTimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
wait: 0.03,
|
||||
waitTimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
load15: 0.5,
|
||||
load15TimeSeries: { labels: {}, labelsArray: [], values: [] },
|
||||
},
|
||||
],
|
||||
groups: null,
|
||||
total: 1,
|
||||
sentAnyHostMetricsData: true,
|
||||
isSendingK8SAgentMetrics: false,
|
||||
endTimeBeforeRetention: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any);
|
||||
params: {} as any,
|
||||
});
|
||||
|
||||
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
user: {
|
||||
role: 'admin',
|
||||
@@ -128,20 +151,11 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
} as any);
|
||||
|
||||
describe('HostsList', () => {
|
||||
it('renders hosts list table', () => {
|
||||
const { container } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<HostsList />
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('renders filters', () => {
|
||||
it('renders hosts list table', async () => {
|
||||
const { container } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
@@ -151,6 +165,23 @@ describe('HostsList', () => {
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(container.querySelector('.filters')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders filters', async () => {
|
||||
const { container } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<HostsList />
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.filters')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, TabsProps, Tag, Tooltip, Typography } from 'antd';
|
||||
import { Progress, Tag, Tooltip, Typography } from 'antd';
|
||||
import { TableColumnType as ColumnType } from 'antd';
|
||||
import {
|
||||
HostData,
|
||||
@@ -12,16 +12,12 @@ import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
} from 'components/QuickFilters/types';
|
||||
import TabLabel from 'components/TabLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import HostsList from './HostsList';
|
||||
|
||||
import './InfraMonitoring.styles.scss';
|
||||
|
||||
export interface HostRowData {
|
||||
@@ -127,14 +123,6 @@ export const getHostListsQuery = (): HostListPayload => ({
|
||||
orderBy: { columnName: 'cpu', order: 'desc' },
|
||||
});
|
||||
|
||||
export const getTabsItems = (): TabsProps['items'] => [
|
||||
{
|
||||
label: <TabLabel label="List View" isDisabled={false} tooltipText="" />,
|
||||
key: PANEL_TYPES.LIST,
|
||||
children: <HostsList />,
|
||||
},
|
||||
];
|
||||
|
||||
export const getHostsListColumns = (): ColumnType<HostRowData>[] => [
|
||||
{
|
||||
title: <div className="hostname-column-header">Hostname</div>,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useIsFetching, useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { connect, useDispatch, useSelector } from 'react-redux';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
@@ -34,6 +35,7 @@ import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { REACT_QUERY_KEY } from '../../../constants/reactQueryKeys';
|
||||
import AutoRefresh from '../AutoRefreshV2';
|
||||
import { DateTimeRangeType } from '../CustomDateTimeModal';
|
||||
import {
|
||||
@@ -352,7 +354,14 @@ function DateTimeSelection({
|
||||
],
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const isRefreshingQueries = useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
|
||||
});
|
||||
const onRefreshHandler = (): void => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
|
||||
});
|
||||
onSelectHandler(selectedTime);
|
||||
onLastRefreshHandler();
|
||||
};
|
||||
@@ -732,7 +741,13 @@ function DateTimeSelection({
|
||||
{showAutoRefresh && selectedTime !== 'custom' && (
|
||||
<div className="refresh-actions">
|
||||
<FormItem hidden={refreshButtonHidden} className="refresh-btn">
|
||||
<Button icon={<SyncOutlined />} onClick={onRefreshHandler} />
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={!!isRefreshingQueries}
|
||||
onClick={(): void => {
|
||||
onRefreshHandler();
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import {
|
||||
getHostLists,
|
||||
HostListPayload,
|
||||
HostListResponse,
|
||||
} from 'api/infraMonitoring/getHostLists';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
type UseGetHostList = (
|
||||
requestData: HostListPayload,
|
||||
options?: UseQueryOptions<
|
||||
SuccessResponse<HostListResponse> | ErrorResponse,
|
||||
Error
|
||||
>,
|
||||
headers?: Record<string, string>,
|
||||
) => UseQueryResult<SuccessResponse<HostListResponse> | ErrorResponse, Error>;
|
||||
|
||||
export const useGetHostList: UseGetHostList = (
|
||||
requestData,
|
||||
options,
|
||||
headers,
|
||||
) => {
|
||||
const queryKey = useMemo(() => {
|
||||
if (options?.queryKey && Array.isArray(options.queryKey)) {
|
||||
return [...options.queryKey];
|
||||
}
|
||||
|
||||
if (options?.queryKey && typeof options.queryKey === 'string') {
|
||||
return options.queryKey;
|
||||
}
|
||||
|
||||
return [REACT_QUERY_KEY.GET_HOST_LIST, requestData];
|
||||
}, [options?.queryKey, requestData]);
|
||||
|
||||
return useQuery<SuccessResponse<HostListResponse> | ErrorResponse, Error>({
|
||||
queryFn: ({ signal }) => getHostLists(requestData, signal, headers),
|
||||
...options,
|
||||
queryKey,
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { ReactQueryDevtools } from 'react-query/devtools';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import AppRoutes from 'AppRoutes';
|
||||
import { AxiosError } from 'axios';
|
||||
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
|
||||
import { ThemeProvider } from 'hooks/useDarkMode';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
@@ -51,6 +53,8 @@ if (container) {
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
<ReactQueryDevtools />
|
||||
<AppProvider>
|
||||
<AppRoutes />
|
||||
</AppProvider>
|
||||
|
||||
193
frontend/src/store/globalTime/__tests__/globalTimeStore.test.ts
Normal file
193
frontend/src/store/globalTime/__tests__/globalTimeStore.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useGlobalTimeStore } from '../globalTimeStore';
|
||||
import { createCustomTimeRange } from '../utils';
|
||||
|
||||
describe('globalTimeStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the store state before each test
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
act(() => {
|
||||
result.current.setSelectedTime('30s', 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have default selectedTime of 30s', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe('30s');
|
||||
});
|
||||
|
||||
it('should have isRefreshEnabled as false by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should have refreshInterval as 0 by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.refreshInterval).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedTime', () => {
|
||||
it('should update selectedTime', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
});
|
||||
|
||||
it('should update refreshInterval when provided', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should keep existing refreshInterval when not provided', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('1h');
|
||||
});
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should enable refresh for relative time with refreshInterval > 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable refresh for relative time with refreshInterval = 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 0);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime, 5000);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should handle various relative time formats', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const timeFormats = ['1m', '5m', '15m', '30m', '1h', '3h', '6h', '1d', '1w'];
|
||||
|
||||
timeFormats.forEach((time) => {
|
||||
act(() => {
|
||||
result.current.setSelectedTime(time, 10000);
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe(time);
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMinMaxTime', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return min/max time for custom time range', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
const {
|
||||
minTime: resultMin,
|
||||
maxTime: resultMax,
|
||||
} = result.current.getMinMaxTime();
|
||||
expect(resultMin).toBe(minTime);
|
||||
expect(resultMax).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should compute fresh min/max time for relative time', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
const now = Date.now() * 1000000;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * 1000000;
|
||||
|
||||
expect(maxTime).toBe(now);
|
||||
expect(minTime).toBe(now - fifteenMinutesNs);
|
||||
});
|
||||
|
||||
it('should return different values on subsequent calls for relative time', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
const first = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time by 1 second
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
const second = result.current.getMinMaxTime();
|
||||
|
||||
// maxTime should be different (1 second later)
|
||||
expect(second.maxTime).toBe(first.maxTime + 1000 * 1000000);
|
||||
expect(second.minTime).toBe(first.minTime + 1000 * 1000000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store isolation', () => {
|
||||
it('should share state between multiple hook instances', () => {
|
||||
const { result: result1 } = renderHook(() => useGlobalTimeStore());
|
||||
const { result: result2 } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result1.current.setSelectedTime('1h', 10000);
|
||||
});
|
||||
|
||||
expect(result2.current.selectedTime).toBe('1h');
|
||||
expect(result2.current.refreshInterval).toBe(10000);
|
||||
expect(result2.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
138
frontend/src/store/globalTime/__tests__/utils.test.ts
Normal file
138
frontend/src/store/globalTime/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
CUSTOM_TIME_SEPARATOR,
|
||||
isCustomTimeRange,
|
||||
parseCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
} from '../utils';
|
||||
|
||||
describe('globalTime/utils', () => {
|
||||
describe('CUSTOM_TIME_SEPARATOR', () => {
|
||||
it('should be defined as ||_||', () => {
|
||||
expect(CUSTOM_TIME_SEPARATOR).toBe('||_||');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCustomTimeRange', () => {
|
||||
it('should return true for custom time range strings', () => {
|
||||
expect(isCustomTimeRange('1000000000||_||2000000000')).toBe(true);
|
||||
expect(isCustomTimeRange('0||_||0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for relative time strings', () => {
|
||||
expect(isCustomTimeRange('15m')).toBe(false);
|
||||
expect(isCustomTimeRange('1h')).toBe(false);
|
||||
expect(isCustomTimeRange('1d')).toBe(false);
|
||||
expect(isCustomTimeRange('30s')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isCustomTimeRange('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCustomTimeRange', () => {
|
||||
it('should create a custom time range string from min and max times', () => {
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const result = createCustomTimeRange(minTime, maxTime);
|
||||
expect(result).toBe(`${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`);
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const result = createCustomTimeRange(0, 0);
|
||||
expect(result).toBe(`0${CUSTOM_TIME_SEPARATOR}0`);
|
||||
});
|
||||
|
||||
it('should handle large nanosecond timestamps', () => {
|
||||
const minTime = 1700000000000000000;
|
||||
const maxTime = 1700000001000000000;
|
||||
const result = createCustomTimeRange(minTime, maxTime);
|
||||
expect(result).toBe(`${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCustomTimeRange', () => {
|
||||
it('should parse a valid custom time range string', () => {
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const timeString = `${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`;
|
||||
const result = parseCustomTimeRange(timeString);
|
||||
expect(result).toEqual({ minTime, maxTime });
|
||||
});
|
||||
|
||||
it('should return null for non-custom time range strings', () => {
|
||||
expect(parseCustomTimeRange('15m')).toBeNull();
|
||||
expect(parseCustomTimeRange('1h')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for invalid numeric values', () => {
|
||||
expect(parseCustomTimeRange(`abc${CUSTOM_TIME_SEPARATOR}def`)).toBeNull();
|
||||
expect(parseCustomTimeRange(`123${CUSTOM_TIME_SEPARATOR}def`)).toBeNull();
|
||||
expect(parseCustomTimeRange(`abc${CUSTOM_TIME_SEPARATOR}456`)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const result = parseCustomTimeRange(`0${CUSTOM_TIME_SEPARATOR}0`);
|
||||
expect(result).toEqual({ minTime: 0, maxTime: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSelectedTime', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should parse custom time range and return min/max values', () => {
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const timeString = createCustomTimeRange(minTime, maxTime);
|
||||
const result = parseSelectedTime(timeString);
|
||||
expect(result).toEqual({ minTime, maxTime });
|
||||
});
|
||||
|
||||
it('should return fallback for invalid custom time range', () => {
|
||||
const invalidCustom = `invalid${CUSTOM_TIME_SEPARATOR}values`;
|
||||
const result = parseSelectedTime(invalidCustom);
|
||||
const now = Date.now() * 1000000;
|
||||
const fallbackDuration = 30 * 1000 * 1000000; // 30s in nanoseconds
|
||||
expect(result.maxTime).toBe(now);
|
||||
expect(result.minTime).toBe(now - fallbackDuration);
|
||||
});
|
||||
|
||||
it('should parse relative time strings using getMinMaxForSelectedTime', () => {
|
||||
const result = parseSelectedTime('15m');
|
||||
const now = Date.now() * 1000000;
|
||||
// 15 minutes in nanoseconds
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * 1000000;
|
||||
|
||||
expect(result.maxTime).toBe(now);
|
||||
expect(result.minTime).toBe(now - fifteenMinutesNs);
|
||||
});
|
||||
|
||||
it('should parse 1h relative time', () => {
|
||||
const result = parseSelectedTime('1h');
|
||||
const now = Date.now() * 1000000;
|
||||
// 1 hour in nanoseconds
|
||||
const oneHourNs = 60 * 60 * 1000 * 1000000;
|
||||
|
||||
expect(result.maxTime).toBe(now);
|
||||
expect(result.minTime).toBe(now - oneHourNs);
|
||||
});
|
||||
|
||||
it('should parse 1d relative time', () => {
|
||||
const result = parseSelectedTime('1d');
|
||||
const now = Date.now() * 1000000;
|
||||
// 1 day in nanoseconds
|
||||
const oneDayNs = 24 * 60 * 60 * 1000 * 1000000;
|
||||
|
||||
expect(result.maxTime).toBe(now);
|
||||
expect(result.minTime).toBe(now - oneDayNs);
|
||||
});
|
||||
});
|
||||
});
|
||||
32
frontend/src/store/globalTime/globalTimeStore.ts
Normal file
32
frontend/src/store/globalTime/globalTimeStore.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { IGlobalTimeStoreActions, IGlobalTimeStoreState } from './types';
|
||||
import { isCustomTimeRange, parseSelectedTime } from './utils';
|
||||
|
||||
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
|
||||
|
||||
export const useGlobalTimeStore = create<IGlobalTimeStore>((set, get) => ({
|
||||
// Initial state
|
||||
selectedTime: '30s',
|
||||
isRefreshEnabled: false,
|
||||
refreshInterval: 0,
|
||||
|
||||
// Actions
|
||||
setSelectedTime: (selectedTime, refreshInterval): void => {
|
||||
set((state) => {
|
||||
const newRefreshInterval = refreshInterval ?? state.refreshInterval;
|
||||
const isCustom = isCustomTimeRange(selectedTime);
|
||||
|
||||
return {
|
||||
selectedTime,
|
||||
refreshInterval: newRefreshInterval,
|
||||
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
getMinMaxTime: (): { minTime: number; maxTime: number } => {
|
||||
const { selectedTime } = get();
|
||||
return parseSelectedTime(selectedTime);
|
||||
},
|
||||
}));
|
||||
9
frontend/src/store/globalTime/index.ts
Normal file
9
frontend/src/store/globalTime/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { useGlobalTimeStore } from './globalTimeStore';
|
||||
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
|
||||
export {
|
||||
createCustomTimeRange,
|
||||
CUSTOM_TIME_SEPARATOR,
|
||||
isCustomTimeRange,
|
||||
parseCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
} from './utils';
|
||||
43
frontend/src/store/globalTime/types.ts
Normal file
43
frontend/src/store/globalTime/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface IGlobalTimeStoreState {
|
||||
/**
|
||||
* The selected time range, can be:
|
||||
* - Relative duration: '1m', '5m', '15m', '1h', '1d', etc.
|
||||
* - Custom range: '<minTimeUnixNano>||_||<maxTimeUnixNano>' format
|
||||
*/
|
||||
selectedTime: string;
|
||||
|
||||
/**
|
||||
* Whether auto-refresh is enabled.
|
||||
* Automatically computed: true for duration-based times, false for custom ranges.
|
||||
*/
|
||||
isRefreshEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The refresh interval in milliseconds (e.g., 5000 for 5s, 30000 for 30s)
|
||||
* Only used when isRefreshEnabled is true
|
||||
*/
|
||||
refreshInterval: number;
|
||||
}
|
||||
|
||||
export interface ParsedTimeRange {
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
}
|
||||
|
||||
export interface IGlobalTimeStoreActions {
|
||||
/**
|
||||
* Set the selected time and optionally the refresh interval.
|
||||
* isRefreshEnabled is automatically computed:
|
||||
* - Custom time ranges: always false
|
||||
* - Duration times with refreshInterval > 0: true
|
||||
* - Duration times with refreshInterval = 0: false
|
||||
*/
|
||||
setSelectedTime: (selectedTime: string, refreshInterval?: number) => void;
|
||||
|
||||
/**
|
||||
* Get the current min/max time values parsed from selectedTime.
|
||||
* For durations, computes fresh values based on Date.now().
|
||||
* For custom ranges, extracts the stored values.
|
||||
*/
|
||||
getMinMaxTime: () => { minTime: number; maxTime: number };
|
||||
}
|
||||
70
frontend/src/store/globalTime/utils.ts
Normal file
70
frontend/src/store/globalTime/utils.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
|
||||
|
||||
import { ParsedTimeRange } from './types';
|
||||
|
||||
/**
|
||||
* Custom time range separator used in the selectedTime string
|
||||
*/
|
||||
export const CUSTOM_TIME_SEPARATOR = '||_||';
|
||||
|
||||
/**
|
||||
* Check if selectedTime represents a custom time range
|
||||
*/
|
||||
export function isCustomTimeRange(selectedTime: string): boolean {
|
||||
return selectedTime.includes(CUSTOM_TIME_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom time range string from min/max times (in nanoseconds)
|
||||
*/
|
||||
export function createCustomTimeRange(
|
||||
minTime: number,
|
||||
maxTime: number,
|
||||
): string {
|
||||
return `${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the custom time range string to get min/max times (in nanoseconds)
|
||||
*/
|
||||
export function parseCustomTimeRange(
|
||||
selectedTime: string,
|
||||
): ParsedTimeRange | null {
|
||||
if (!isCustomTimeRange(selectedTime)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [minStr, maxStr] = selectedTime.split(CUSTOM_TIME_SEPARATOR);
|
||||
const minTime = parseInt(minStr, 10);
|
||||
const maxTime = parseInt(maxStr, 10);
|
||||
|
||||
if (Number.isNaN(minTime) || Number.isNaN(maxTime)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { minTime, maxTime };
|
||||
}
|
||||
|
||||
const fallbackDuration = 30 * 1000 * 1000000; // 30s
|
||||
|
||||
/**
|
||||
* Parse the selectedTime string to get min/max time values.
|
||||
* For relative times, computes fresh values based on Date.now().
|
||||
* For custom times, extracts the stored min/max values.
|
||||
*/
|
||||
export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
|
||||
if (isCustomTimeRange(selectedTime)) {
|
||||
const parsed = parseCustomTimeRange(selectedTime);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
// Fallback to current time if parsing fails
|
||||
const now = Date.now() * 1000000;
|
||||
return { minTime: now - fallbackDuration, maxTime: now };
|
||||
}
|
||||
|
||||
// It's a relative time like '15m', '1h', etc.
|
||||
// Use getMinMaxForSelectedTime which computes from Date.now()
|
||||
return getMinMaxForSelectedTime(selectedTime as Time, 0, 0);
|
||||
}
|
||||
@@ -16,11 +16,10 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/thirdpartyapitypes"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/govaluate"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/kafka"
|
||||
queues2 "github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/queues"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
promModel "github.com/prometheus/common/model"
|
||||
@@ -962,57 +961,6 @@ func ParseQueryRangeParams(r *http.Request) (*v3.QueryRangeParamsV3, *model.ApiE
|
||||
}
|
||||
}
|
||||
|
||||
for _, query := range queryRangeParams.CompositeQuery.BuilderQueries {
|
||||
if query.Disabled {
|
||||
continue
|
||||
}
|
||||
if query.Filters == nil {
|
||||
continue
|
||||
}
|
||||
for _, filter := range query.Filters.Items {
|
||||
if filter.Value == nil {
|
||||
query.Disabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disable queries that reference disabled queries in their expressions
|
||||
// This needs to be done iteratively until no more queries are disabled
|
||||
// because disabling one query may cause another dependent query to need disabling
|
||||
for {
|
||||
changed := false
|
||||
for _, query := range queryRangeParams.CompositeQuery.BuilderQueries {
|
||||
if query.Disabled {
|
||||
continue
|
||||
}
|
||||
// Skip non-formula queries (where expression equals query name)
|
||||
if query.QueryName == query.Expression {
|
||||
continue
|
||||
}
|
||||
|
||||
expression, err := govaluate.NewEvaluableExpressionWithFunctions(query.Expression, postprocess.EvalFuncs())
|
||||
if err != nil {
|
||||
// Expression parsing already validated earlier, skip gracefully
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if any variable in the expression references a disabled query
|
||||
for _, v := range expression.Vars() {
|
||||
if refQuery, ok := queryRangeParams.CompositeQuery.BuilderQueries[v]; ok {
|
||||
if refQuery.Disabled {
|
||||
query.Disabled = true
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return queryRangeParams, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1574,215 +1574,3 @@ func TestParseQueryRangeParamsStepIntervalAdjustment(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQueryRangeParams_DisablesQueriesWithEmptyFilterValues(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
filterValue interface{}
|
||||
expectDisabled bool
|
||||
}{
|
||||
{
|
||||
name: "nil filter value disables query",
|
||||
filterValue: nil,
|
||||
expectDisabled: true,
|
||||
},
|
||||
{
|
||||
name: "empty string filter value keeps query enabled",
|
||||
filterValue: "",
|
||||
expectDisabled: false,
|
||||
},
|
||||
{
|
||||
name: "non-empty string filter value keeps query enabled",
|
||||
filterValue: "some-value",
|
||||
expectDisabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
queryRangeParams := v3.QueryRangeParamsV3{
|
||||
Start: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
End: time.Now().UnixMilli(),
|
||||
Step: 60,
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
PanelType: v3.PanelTypeGraph,
|
||||
BuilderQueries: map[string]*v3.BuilderQuery{
|
||||
"A": {
|
||||
QueryName: "A",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
AggregateOperator: v3.AggregateOperatorSum,
|
||||
AggregateAttribute: v3.AttributeKey{Key: "test_metric", DataType: "float64"},
|
||||
Filters: &v3.FilterSet{
|
||||
Operator: "AND",
|
||||
Items: []v3.FilterItem{
|
||||
{
|
||||
Key: v3.AttributeKey{Key: "namespace"},
|
||||
Operator: v3.FilterOperatorEqual,
|
||||
Value: tc.filterValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
Expression: "A",
|
||||
StepInterval: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
Variables: map[string]interface{}{},
|
||||
}
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
err := json.NewEncoder(body).Encode(queryRangeParams)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v3/query_range", body)
|
||||
|
||||
p, apiErr := ParseQueryRangeParams(req)
|
||||
require.Nil(t, apiErr)
|
||||
|
||||
assert.Equal(t, tc.expectDisabled, p.CompositeQuery.BuilderQueries["A"].Disabled,
|
||||
"Query disabled state mismatch for filter value: %v", tc.filterValue)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQueryRangeParams_DisablesExpressionQueriesWithDisabledDependencies(t *testing.T) {
|
||||
// Test that expression queries are disabled when their dependencies are disabled
|
||||
queryRangeParams := v3.QueryRangeParamsV3{
|
||||
Start: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
End: time.Now().UnixMilli(),
|
||||
Step: 60,
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
PanelType: v3.PanelTypeGraph,
|
||||
BuilderQueries: map[string]*v3.BuilderQuery{
|
||||
"A": {
|
||||
QueryName: "A",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
AggregateOperator: v3.AggregateOperatorSum,
|
||||
AggregateAttribute: v3.AttributeKey{Key: "metric_a", DataType: "float64"},
|
||||
Filters: &v3.FilterSet{
|
||||
Operator: "AND",
|
||||
Items: []v3.FilterItem{
|
||||
{
|
||||
Key: v3.AttributeKey{Key: "namespace"},
|
||||
Operator: v3.FilterOperatorEqual,
|
||||
Value: "", // Empty value will disable this query
|
||||
},
|
||||
},
|
||||
},
|
||||
Expression: "A",
|
||||
StepInterval: 60,
|
||||
},
|
||||
"B": {
|
||||
QueryName: "B",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
AggregateOperator: v3.AggregateOperatorSum,
|
||||
AggregateAttribute: v3.AttributeKey{Key: "metric_b", DataType: "float64"},
|
||||
Expression: "B",
|
||||
StepInterval: 60,
|
||||
},
|
||||
"F1": {
|
||||
QueryName: "F1",
|
||||
Expression: "A + B", // This depends on A, which is disabled
|
||||
},
|
||||
},
|
||||
},
|
||||
Variables: map[string]interface{}{},
|
||||
}
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
err := json.NewEncoder(body).Encode(queryRangeParams)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v3/query_range", body)
|
||||
|
||||
p, apiErr := ParseQueryRangeParams(req)
|
||||
require.Nil(t, apiErr)
|
||||
|
||||
// A should be disabled because of empty filter value
|
||||
assert.True(t, p.CompositeQuery.BuilderQueries["A"].Disabled, "Query A should be disabled due to empty filter value")
|
||||
|
||||
// B should not be disabled
|
||||
assert.False(t, p.CompositeQuery.BuilderQueries["B"].Disabled, "Query B should not be disabled")
|
||||
|
||||
// F1 should be disabled because it depends on disabled query A
|
||||
assert.True(t, p.CompositeQuery.BuilderQueries["F1"].Disabled, "Query F1 should be disabled because it depends on disabled query A")
|
||||
}
|
||||
|
||||
func TestParseQueryRangeParams_DisablesMultipleFormulaDependencies(t *testing.T) {
|
||||
// Test that multiple formula queries depending on a disabled query are all disabled
|
||||
queryRangeParams := v3.QueryRangeParamsV3{
|
||||
Start: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
End: time.Now().UnixMilli(),
|
||||
Step: 60,
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
PanelType: v3.PanelTypeGraph,
|
||||
BuilderQueries: map[string]*v3.BuilderQuery{
|
||||
"A": {
|
||||
QueryName: "A",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
AggregateOperator: v3.AggregateOperatorSum,
|
||||
AggregateAttribute: v3.AttributeKey{Key: "metric_a", DataType: "float64"},
|
||||
Filters: &v3.FilterSet{
|
||||
Operator: "AND",
|
||||
Items: []v3.FilterItem{
|
||||
{
|
||||
Key: v3.AttributeKey{Key: "namespace"},
|
||||
Operator: v3.FilterOperatorEqual,
|
||||
Value: nil, // nil value will disable this query
|
||||
},
|
||||
},
|
||||
},
|
||||
Expression: "A",
|
||||
StepInterval: 60,
|
||||
},
|
||||
"B": {
|
||||
QueryName: "B",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
AggregateOperator: v3.AggregateOperatorSum,
|
||||
AggregateAttribute: v3.AttributeKey{Key: "metric_b", DataType: "float64"},
|
||||
Expression: "B",
|
||||
StepInterval: 60,
|
||||
},
|
||||
"C": {
|
||||
QueryName: "C",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
AggregateOperator: v3.AggregateOperatorSum,
|
||||
AggregateAttribute: v3.AttributeKey{Key: "metric_c", DataType: "float64"},
|
||||
Expression: "C",
|
||||
StepInterval: 60,
|
||||
},
|
||||
"F1": {
|
||||
QueryName: "F1",
|
||||
Expression: "A + B", // Depends on A (disabled) and B
|
||||
},
|
||||
"F2": {
|
||||
QueryName: "F2",
|
||||
Expression: "B + C", // Depends on B and C (both enabled)
|
||||
},
|
||||
},
|
||||
},
|
||||
Variables: map[string]interface{}{},
|
||||
}
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
err := json.NewEncoder(body).Encode(queryRangeParams)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v3/query_range", body)
|
||||
|
||||
p, apiErr := ParseQueryRangeParams(req)
|
||||
require.Nil(t, apiErr)
|
||||
|
||||
// A should be disabled because of nil filter value
|
||||
assert.True(t, p.CompositeQuery.BuilderQueries["A"].Disabled, "Query A should be disabled")
|
||||
|
||||
// B and C should not be disabled
|
||||
assert.False(t, p.CompositeQuery.BuilderQueries["B"].Disabled, "Query B should not be disabled")
|
||||
assert.False(t, p.CompositeQuery.BuilderQueries["C"].Disabled, "Query C should not be disabled")
|
||||
|
||||
// F1 should be disabled because it depends on A
|
||||
assert.True(t, p.CompositeQuery.BuilderQueries["F1"].Disabled, "Query F1 should be disabled because it depends on A")
|
||||
|
||||
// F2 should NOT be disabled because both B and C are enabled
|
||||
assert.False(t, p.CompositeQuery.BuilderQueries["F2"].Disabled, "Query F2 should not be disabled because both B and C are enabled")
|
||||
}
|
||||
|
||||
@@ -164,9 +164,6 @@ func (q *querier) runBuilderQueries(ctx context.Context, orgID valuer.UUID, para
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for queryName, builderQuery := range params.CompositeQuery.BuilderQueries {
|
||||
if builderQuery.Disabled {
|
||||
continue
|
||||
}
|
||||
if queryName == builderQuery.Expression {
|
||||
wg.Add(1)
|
||||
go q.runBuilderQuery(ctx, orgID, builderQuery, params, cacheKeys, ch, &wg)
|
||||
|
||||
@@ -2271,79 +2271,3 @@ func Test_querier_Logs_runWindowBasedListQueryAsc(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2DisabledQueriesAreNotExecuted(t *testing.T) {
|
||||
// Test that queries with Disabled: true are not executed
|
||||
params := &v3.QueryRangeParamsV3{
|
||||
Start: 1675115596722,
|
||||
End: 1675115596722 + 120*60*1000,
|
||||
Step: 60,
|
||||
Version: "v4",
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
PanelType: v3.PanelTypeGraph,
|
||||
BuilderQueries: map[string]*v3.BuilderQuery{
|
||||
"A": {
|
||||
QueryName: "A",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
Temporality: v3.Delta,
|
||||
StepInterval: 60,
|
||||
AggregateAttribute: v3.AttributeKey{Key: "test_metric", DataType: "float64", IsColumn: true},
|
||||
AggregateOperator: v3.AggregateOperatorSumRate,
|
||||
TimeAggregation: v3.TimeAggregationRate,
|
||||
SpaceAggregation: v3.SpaceAggregationSum,
|
||||
Expression: "A",
|
||||
Disabled: true, // This query is disabled
|
||||
},
|
||||
"B": {
|
||||
QueryName: "B",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
Temporality: v3.Delta,
|
||||
StepInterval: 60,
|
||||
AggregateAttribute: v3.AttributeKey{Key: "other_metric", DataType: "float64", IsColumn: true},
|
||||
AggregateOperator: v3.AggregateOperatorSumRate,
|
||||
TimeAggregation: v3.TimeAggregationRate,
|
||||
SpaceAggregation: v3.SpaceAggregationSum,
|
||||
Expression: "B",
|
||||
Disabled: false, // This query is enabled
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cacheOpts := cache.Memory{
|
||||
NumCounters: 10 * 1000,
|
||||
MaxCost: 1 << 26,
|
||||
}
|
||||
c, err := cachetest.New(cache.Config{Provider: "memory", Memory: cacheOpts})
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := QuerierOptions{
|
||||
Cache: c,
|
||||
Reader: nil,
|
||||
FluxInterval: 5 * time.Minute,
|
||||
KeyGenerator: queryBuilder.NewKeyGenerator(),
|
||||
TestingMode: true,
|
||||
ReturnedSeries: []*v3.Series{
|
||||
{
|
||||
Labels: map[string]string{"__name__": "other_metric"},
|
||||
Points: []v3.Point{
|
||||
{Timestamp: 1675115596722, Value: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
q := NewQuerier(opts)
|
||||
|
||||
_, errByName, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), params)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, errByName)
|
||||
|
||||
// Verify only the enabled query (B) was executed, not the disabled one (A)
|
||||
executedQueries := q.QueriesExecuted()
|
||||
require.Len(t, executedQueries, 1, "Expected exactly 1 query to be executed (only B, not A)")
|
||||
|
||||
// The executed query should be for "other_metric" (query B), not "test_metric" (query A)
|
||||
require.Contains(t, executedQueries[0], "other_metric", "Expected query B to be executed")
|
||||
require.NotContains(t, executedQueries[0], "test_metric", "Expected query A (disabled) to NOT be executed")
|
||||
}
|
||||
|
||||
@@ -180,9 +180,6 @@ func (qb *QueryBuilder) PrepareQueries(params *v3.QueryRangeParamsV3) (map[strin
|
||||
if compositeQuery != nil {
|
||||
// Build queries for each builder query
|
||||
for queryName, query := range compositeQuery.BuilderQueries {
|
||||
if query.Disabled {
|
||||
continue
|
||||
}
|
||||
// making a local clone since we should not update the global params if there is sift by
|
||||
start := params.Start
|
||||
end := params.End
|
||||
@@ -249,9 +246,6 @@ func (qb *QueryBuilder) PrepareQueries(params *v3.QueryRangeParamsV3) (map[strin
|
||||
|
||||
// Build queries for each expression
|
||||
for _, query := range compositeQuery.BuilderQueries {
|
||||
if query.Disabled {
|
||||
continue
|
||||
}
|
||||
if query.Expression != query.QueryName {
|
||||
expression, err := govaluate.NewEvaluableExpressionWithFunctions(query.Expression, EvalFuncs)
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ func TestBuildQueryWithThreeOrMoreQueriesRefAndFormula(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Expression: "A",
|
||||
Disabled: false,
|
||||
Disabled: true,
|
||||
StepInterval: 60,
|
||||
OrderBy: []v3.OrderBy{
|
||||
{
|
||||
@@ -292,7 +292,7 @@ func TestBuildQueryWithThreeOrMoreQueriesRefAndFormula(t *testing.T) {
|
||||
Items: []v3.FilterItem{},
|
||||
},
|
||||
Expression: "B",
|
||||
Disabled: false,
|
||||
Disabled: true,
|
||||
StepInterval: 60,
|
||||
OrderBy: []v3.OrderBy{
|
||||
{
|
||||
@@ -341,62 +341,6 @@ func TestBuildQueryWithThreeOrMoreQueriesRefAndFormula(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
})
|
||||
t.Run("TestFormulaWithDisabledDependencies", func(t *testing.T) {
|
||||
// Test that formula queries are skipped when their dependencies are disabled
|
||||
q := &v3.QueryRangeParamsV3{
|
||||
Start: 1735036101000,
|
||||
End: 1735637901000,
|
||||
Step: 60,
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
PanelType: v3.PanelTypeGraph,
|
||||
BuilderQueries: map[string]*v3.BuilderQuery{
|
||||
"A": {
|
||||
QueryName: "A",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
AggregateOperator: v3.AggregateOperatorAvg,
|
||||
AggregateAttribute: v3.AttributeKey{
|
||||
Key: "system.memory.usage",
|
||||
DataType: v3.AttributeKeyDataTypeFloat64,
|
||||
},
|
||||
Expression: "A",
|
||||
Disabled: true, // A is disabled
|
||||
StepInterval: 60,
|
||||
},
|
||||
"B": {
|
||||
QueryName: "B",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
AggregateOperator: v3.AggregateOperatorSum,
|
||||
AggregateAttribute: v3.AttributeKey{
|
||||
Key: "system.network.io",
|
||||
DataType: v3.AttributeKeyDataTypeFloat64,
|
||||
},
|
||||
Expression: "B",
|
||||
Disabled: true, // B is disabled
|
||||
StepInterval: 60,
|
||||
},
|
||||
"F1": {
|
||||
QueryName: "F1",
|
||||
Expression: "A + B",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
qbOptions := QueryBuilderOptions{
|
||||
BuildMetricQuery: metricsv3.PrepareMetricQuery,
|
||||
}
|
||||
qb := NewQueryBuilder(qbOptions)
|
||||
|
||||
queries, err := qb.PrepareQueries(q)
|
||||
require.NoError(t, err)
|
||||
// A and B are disabled, so they should not be in the queries map
|
||||
require.NotContains(t, queries, "A")
|
||||
require.NotContains(t, queries, "B")
|
||||
// F1 depends on disabled queries, so it should produce invalid/empty SQL
|
||||
// The formula is still "built" but with empty subqueries since dependencies are missing
|
||||
// This is expected - the parser.go disables F1 before PrepareQueries is called
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeltaQueryBuilder(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user