mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-30 23:50:27 +01:00
Compare commits
2 Commits
issue_4360
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e64bad90 | ||
|
|
cc3da72aa5 |
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
@@ -113,11 +112,9 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
}
|
||||
|
||||
// initiate agent config handler
|
||||
llmCostFeature := impllmpricingrule.NewLLMCostFeature(signoz.Modules.LLMPricingRule)
|
||||
|
||||
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController, llmCostFeature},
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"ts-jest": "29.4.6",
|
||||
"ts-node": "^10.2.1",
|
||||
"typescript-plugin-css-modules": "5.2.0",
|
||||
"use-sync-external-store": "1.6.0",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
"vite-plugin-compression": "0.5.1",
|
||||
"vite-plugin-image-optimizer": "2.0.3",
|
||||
@@ -266,4 +267,4 @@
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
@@ -190,16 +187,19 @@ function K8sBaseDetails<T>({
|
||||
);
|
||||
|
||||
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
|
||||
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const { startMs, endMs } = useMemo(() => {
|
||||
const { minTime: startNs, maxTime: endNs } = getMinMaxTime(selectedTime);
|
||||
|
||||
return {
|
||||
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
|
||||
};
|
||||
}, [getMinMaxTime, selectedTime]);
|
||||
const { startMs, endMs } = useMemo(
|
||||
() => ({
|
||||
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
|
||||
}),
|
||||
[lastComputedMinMax],
|
||||
);
|
||||
|
||||
const [modalTimeRange, setModalTimeRange] = useState(() => ({
|
||||
startTime: startMs,
|
||||
@@ -246,7 +246,7 @@ function K8sBaseDetails<T>({
|
||||
`${queryKeyPrefix}EntityDetails`,
|
||||
selectedItem,
|
||||
),
|
||||
[queryKeyPrefix, selectedItem, selectedTime],
|
||||
[getAutoRefreshQueryKey, queryKeyPrefix, selectedItem, selectedTime],
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@@ -16,10 +16,7 @@ import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
@@ -114,6 +111,9 @@ export function K8sBaseList<T>({
|
||||
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
|
||||
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
return getAutoRefreshQueryKey(
|
||||
@@ -127,6 +127,7 @@ export function K8sBaseList<T>({
|
||||
JSON.stringify(groupBy),
|
||||
);
|
||||
}, [
|
||||
getAutoRefreshQueryKey,
|
||||
selectedTime,
|
||||
entity,
|
||||
pageSize,
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
} from 'antd';
|
||||
import { CornerDownRight } from 'lucide-react';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
|
||||
@@ -118,6 +115,9 @@ export function K8sExpandedRow<T>({
|
||||
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
|
||||
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
return getAutoRefreshQueryKey(selectedTime, [
|
||||
@@ -126,7 +126,7 @@ export function K8sExpandedRow<T>({
|
||||
JSON.stringify(queryFilters),
|
||||
JSON.stringify(orderBy),
|
||||
]);
|
||||
}, [selectedTime, record.key, queryFilters, orderBy]);
|
||||
}, [getAutoRefreshQueryKey, selectedTime, record.key, queryFilters, orderBy]);
|
||||
|
||||
const { data, isFetching, isLoading, isError } = useQuery({
|
||||
queryKey,
|
||||
|
||||
@@ -240,7 +240,7 @@ function DashboardsList(): JSX.Element {
|
||||
isLocked: !!e.locked || false,
|
||||
lastUpdatedBy: e.updatedBy,
|
||||
image: e.data.image || Base64Icons[0],
|
||||
variables: e.data.variables,
|
||||
variables: e.data.variables ?? {},
|
||||
widgets: e.data.widgets,
|
||||
layout: e.data.layout,
|
||||
panelMap: e.data.panelMap,
|
||||
|
||||
@@ -17,7 +17,7 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import {
|
||||
useGlobalTimeQueryInvalidate,
|
||||
useIsGlobalTimeQueryRefreshing,
|
||||
} from 'hooks/globalTime';
|
||||
} from 'store/globalTime';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
@@ -101,14 +101,14 @@ function DateTimeSelection({
|
||||
if (modalInitialStartTime !== undefined) {
|
||||
initialModalStartTime = modalInitialStartTime;
|
||||
} else if (searchStartTime) {
|
||||
initialModalStartTime = parseInt(searchStartTime, 10);
|
||||
initialModalStartTime = Number.parseInt(searchStartTime, 10);
|
||||
}
|
||||
|
||||
let initialModalEndTime = 0;
|
||||
if (modalInitialEndTime !== undefined) {
|
||||
initialModalEndTime = modalInitialEndTime;
|
||||
} else if (searchEndTime) {
|
||||
initialModalEndTime = parseInt(searchEndTime, 10);
|
||||
initialModalEndTime = Number.parseInt(searchEndTime, 10);
|
||||
}
|
||||
|
||||
const [modalStartTime, setModalStartTime] = useState<number>(
|
||||
@@ -159,9 +159,11 @@ function DateTimeSelection({
|
||||
const getTime = useCallback((): [number, number] | undefined => {
|
||||
if (searchEndTime && searchStartTime) {
|
||||
const startDate = dayjs(
|
||||
new Date(parseInt(getTimeString(searchStartTime), 10)),
|
||||
new Date(Number.parseInt(getTimeString(searchStartTime), 10)),
|
||||
);
|
||||
const endDate = dayjs(
|
||||
new Date(Number.parseInt(getTimeString(searchEndTime), 10)),
|
||||
);
|
||||
const endDate = dayjs(new Date(parseInt(getTimeString(searchEndTime), 10)));
|
||||
|
||||
return [startDate.toDate().getTime() || 0, endDate.toDate().getTime() || 0];
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
expect(orders).toContain(0);
|
||||
expect(orders).toContain(1);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.order).toBe(5);
|
||||
expect(result.data.variables!.v1.order).toBe(5);
|
||||
});
|
||||
|
||||
it('assigns unique orders across multiple variables that all lack an order', () => {
|
||||
@@ -97,7 +97,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
// All three newly assigned orders must be distinct
|
||||
expect(new Set(orders).size).toBe(3);
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toMatch(
|
||||
expect(result.data.variables!.v1.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
@@ -125,7 +125,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toBe('keep-me');
|
||||
expect(result.data.variables!.v1.id).toBe('keep-me');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('hello');
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('hello');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing defaultValue', () => {
|
||||
@@ -163,7 +163,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('keep');
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('keep');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('staging');
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('staging');
|
||||
});
|
||||
|
||||
it('applies localStorage allSelected over DB value', () => {
|
||||
@@ -196,7 +196,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
|
||||
@@ -237,8 +237,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(false);
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set allSelected=false when showALLOption is false', () => {
|
||||
@@ -258,8 +258,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes array URL value to single value for single-select variable', () => {
|
||||
@@ -277,7 +277,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('prod');
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('prod');
|
||||
});
|
||||
|
||||
it('wraps single URL value in array for multi-select variable', () => {
|
||||
@@ -292,7 +292,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toStrictEqual(['prod']);
|
||||
expect(result.data.variables!.v1.selectedValue).toStrictEqual(['prod']);
|
||||
});
|
||||
|
||||
it('looks up URL variable by variable id when name is absent', () => {
|
||||
@@ -306,7 +306,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('fallback');
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,11 +327,11 @@ describe('useTransformDashboardVariables', () => {
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
const originalValue = dashboard.data.variables.v1.selectedValue;
|
||||
const originalValue = dashboard.data.variables!.v1.selectedValue;
|
||||
|
||||
transformDashboardVariables(dashboard);
|
||||
|
||||
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
|
||||
expect(dashboard.data.variables!.v1.selectedValue).toBe(originalValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,11 +22,12 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
|
||||
localStorageVariables: any,
|
||||
): Dashboard => {
|
||||
const updatedData = data;
|
||||
if (data && localStorageVariables) {
|
||||
const updatedVariables = data.data.variables;
|
||||
const variables = data?.data?.variables;
|
||||
if (data && localStorageVariables && variables) {
|
||||
const updatedVariables = variables;
|
||||
const variablesFromUrl = getUrlVariables();
|
||||
Object.keys(data.data.variables).forEach((variable) => {
|
||||
const variableData = data.data.variables[variable];
|
||||
Object.keys(variables).forEach((variable) => {
|
||||
const variableData = variables[variable];
|
||||
|
||||
// values from url
|
||||
const urlVariable = variableData?.name
|
||||
@@ -34,7 +35,7 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
|
||||
: variablesFromUrl[variableData.id];
|
||||
|
||||
let updatedVariable = {
|
||||
...data.data.variables[variable],
|
||||
...variables[variable],
|
||||
...localStorageVariables[variableData.name as any],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
|
||||
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
/**
|
||||
* Use when you want to invalida any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
|
||||
*/
|
||||
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useCallback(async () => {
|
||||
return await queryClient.invalidateQueries({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
|
||||
});
|
||||
}, [queryClient]);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useIsFetching } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
/**
|
||||
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
|
||||
*/
|
||||
export function useIsGlobalTimeQueryRefreshing(): boolean {
|
||||
return (
|
||||
useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
|
||||
}) > 0
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ function DashboardWidgetInternal({
|
||||
setDashboardData(updatedDashboardData);
|
||||
setDashboardVariablesStore({
|
||||
dashboardId,
|
||||
variables: updatedDashboardData.data.variables,
|
||||
variables: updatedDashboardData.data.variables ?? {},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,6 +41,20 @@ describe('dashboardVariablesStoreUtils', () => {
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when variables is undefined', () => {
|
||||
const result = buildSortedVariablesArray(
|
||||
undefined as unknown as IDashboardVariables,
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when variables is null', () => {
|
||||
const result = buildSortedVariablesArray(
|
||||
null as unknown as IDashboardVariables,
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should create copies of variables (not references)', () => {
|
||||
const original = createVariable({ name: 'a', order: 0 });
|
||||
const variables: IDashboardVariables = { a: original };
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
* Build a sorted array of variables by their order property
|
||||
*/
|
||||
export function buildSortedVariablesArray(
|
||||
variables: IDashboardVariables,
|
||||
variables?: IDashboardVariables,
|
||||
): IDashboardVariable[] {
|
||||
const sortedVariablesArray: IDashboardVariable[] = [];
|
||||
|
||||
Object.values(variables).forEach((value) => {
|
||||
Object.values(variables ?? {}).forEach((value) => {
|
||||
sortedVariablesArray.push({ ...value });
|
||||
});
|
||||
|
||||
|
||||
71
frontend/src/store/globalTime/GlobalTimeContext.tsx
Normal file
71
frontend/src/store/globalTime/GlobalTimeContext.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
createContext,
|
||||
ReactNode,
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import get from 'api/browser/localstorage/get';
|
||||
|
||||
import {
|
||||
createGlobalTimeStore,
|
||||
defaultGlobalTimeStore,
|
||||
GlobalTimeStoreApi,
|
||||
} from './globalTimeStore';
|
||||
import { GlobalTimeProviderOptions, GlobalTimeSelectedTime } from './types';
|
||||
import { usePersistence } from './usePersistence';
|
||||
import { useQueryCacheSync } from './useQueryCacheSync';
|
||||
import { useUrlSync } from './useUrlSync';
|
||||
import { useComputedMinMaxSync } from 'store/globalTime/useComputedMinMaxSync';
|
||||
|
||||
export const GlobalTimeContext = createContext<GlobalTimeStoreApi | null>(null);
|
||||
|
||||
export function GlobalTimeProvider({
|
||||
children,
|
||||
name,
|
||||
inheritGlobalTime = false,
|
||||
initialTime,
|
||||
enableUrlParams = false,
|
||||
removeQueryParamsOnUnmount = false,
|
||||
localStoragePersistKey,
|
||||
refreshInterval: initialRefreshInterval,
|
||||
}: GlobalTimeProviderOptions & { children: ReactNode }): JSX.Element {
|
||||
const parentStore = useContext(GlobalTimeContext);
|
||||
const globalStore = parentStore ?? defaultGlobalTimeStore;
|
||||
|
||||
const resolveInitialTime = (): GlobalTimeSelectedTime => {
|
||||
if (inheritGlobalTime) {
|
||||
return globalStore.getState().selectedTime;
|
||||
}
|
||||
if (localStoragePersistKey) {
|
||||
const stored = get(localStoragePersistKey);
|
||||
if (stored) {
|
||||
return stored as GlobalTimeSelectedTime;
|
||||
}
|
||||
}
|
||||
return initialTime ?? DEFAULT_TIME_RANGE;
|
||||
};
|
||||
|
||||
// Create isolated store (stable reference)
|
||||
const [store] = useState(() =>
|
||||
createGlobalTimeStore({
|
||||
name,
|
||||
selectedTime: resolveInitialTime(),
|
||||
refreshInterval: initialRefreshInterval ?? 0,
|
||||
}),
|
||||
);
|
||||
|
||||
useComputedMinMaxSync(store);
|
||||
useQueryCacheSync(store);
|
||||
useUrlSync(store, enableUrlParams, removeQueryParamsOnUnmount);
|
||||
usePersistence(store, localStoragePersistKey);
|
||||
|
||||
return (
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import set from 'api/browser/localstorage/set';
|
||||
|
||||
import { GlobalTimeProvider } from '../GlobalTimeContext';
|
||||
import { useGlobalTime } from '../hooks';
|
||||
import { GlobalTimeProviderOptions } from '../types';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
jest.mock('api/browser/localstorage/set');
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createWrapper = (
|
||||
providerProps: GlobalTimeProviderOptions,
|
||||
nuqsProps?: { searchParams?: string },
|
||||
) => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams={nuqsProps?.searchParams}>
|
||||
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('GlobalTimeProvider', () => {
|
||||
describe('name prop', () => {
|
||||
it('should pass name to store when provided', () => {
|
||||
const wrapper = createWrapper({ name: 'test-drawer' });
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.name), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe('test-drawer');
|
||||
});
|
||||
|
||||
it('should have undefined name when not provided', () => {
|
||||
const wrapper = createWrapper({});
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.name), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('store isolation', () => {
|
||||
it('should create isolated store for each provider', () => {
|
||||
const wrapper1 = createWrapper({ initialTime: '1h' });
|
||||
const wrapper2 = createWrapper({ initialTime: '15m' });
|
||||
|
||||
const { result: result1 } = renderHook(
|
||||
() => useGlobalTime((s) => s.selectedTime),
|
||||
{ wrapper: wrapper1 },
|
||||
);
|
||||
const { result: result2 } = renderHook(
|
||||
() => useGlobalTime((s) => s.selectedTime),
|
||||
{ wrapper: wrapper2 },
|
||||
);
|
||||
|
||||
expect(result1.current).toBe('1h');
|
||||
expect(result2.current).toBe('15m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inheritGlobalTime', () => {
|
||||
it('should inherit time from parent store when inheritGlobalTime is true', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime>{children}</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// Should inherit '6h' from parent provider
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should use initialTime when inheritGlobalTime is false', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// Should use its own initialTime, not parent's
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams="?relativeTime=1h">
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use inherited time when URL params are empty', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams="">
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// No URL params, should keep inherited value
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should prefer custom time URL params over inheritGlobalTime', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
|
||||
>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// URL custom time params should override inherited time
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL sync', () => {
|
||||
it('should read relativeTime from URL on mount', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{ searchParams: '?relativeTime=1h' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should read custom time from URL on mount', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom URL keys when provided', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{
|
||||
enableUrlParams: {
|
||||
relativeTimeKey: 'modalTime',
|
||||
},
|
||||
},
|
||||
{ searchParams: '?modalTime=3h' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('3h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom startTimeKey and endTimeKey when provided', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{
|
||||
enableUrlParams: {
|
||||
startTimeKey: 'customStart',
|
||||
endTimeKey: 'customEnd',
|
||||
},
|
||||
},
|
||||
{ searchParams: `?customStart=${startTime}&customEnd=${endTime}` },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT read from URL when enableUrlParams is false', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: false, initialTime: '15m' },
|
||||
{ searchParams: '?relativeTime=1h' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should use initialTime, not URL value
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should prefer startTime/endTime over relativeTime when both present in URL', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{
|
||||
searchParams: `?relativeTime=15m&startTime=${startTime}&endTime=${endTime}`,
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
// Should use startTime/endTime, not relativeTime
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use initialTime when URL has invalid time values', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true, initialTime: '15m' },
|
||||
{ searchParams: '?startTime=invalid&endTime=also-invalid' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// parseAsInteger returns null for invalid values, so should fallback to initialTime
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should update store when custom time is set from URL with only startTime and endTime', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify selectedTime is a custom time range string
|
||||
expect(result.current.selectedTime).toContain('||_||');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeQueryParamsOnUnmount', () => {
|
||||
const createUnmountTestWrapper = (
|
||||
getQueryString: () => string,
|
||||
setQueryString: (qs: string) => void,
|
||||
) => {
|
||||
return function TestWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
const queryClient = createTestQueryClient();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={getQueryString()}
|
||||
onUrlUpdate={(event): void => {
|
||||
setQueryString(event.queryString);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
it('should remove URL params when provider unmounts with removeQueryParamsOnUnmount=true', async () => {
|
||||
let currentQueryString = 'relativeTime=1h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// URL params should be removed
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('relativeTime');
|
||||
expect(currentQueryString).not.toContain('startTime');
|
||||
expect(currentQueryString).not.toContain('endTime');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT remove URL params when provider unmounts with removeQueryParamsOnUnmount=false', async () => {
|
||||
let currentQueryString = 'relativeTime=1h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount={false}>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// Wait a tick to ensure cleanup effects would have run
|
||||
await waitFor(() => {
|
||||
// URL params should still be present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove custom time URL params on unmount', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
let currentQueryString = `startTime=${startTime}&endTime=${endTime}`;
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime(), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('startTime');
|
||||
expect(currentQueryString).toContain('endTime');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// URL params should be removed
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('startTime');
|
||||
expect(currentQueryString).not.toContain('endTime');
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove custom URL key params on unmount', async () => {
|
||||
let currentQueryString = 'modalTime=3h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider
|
||||
enableUrlParams={{
|
||||
relativeTimeKey: 'modalTime',
|
||||
}}
|
||||
removeQueryParamsOnUnmount
|
||||
>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('modalTime=3h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// URL params should be removed
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('modalTime');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT remove URL params when enableUrlParams is false', async () => {
|
||||
let currentQueryString = 'relativeTime=1h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams={false} removeQueryParamsOnUnmount>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// Wait a tick
|
||||
await waitFor(() => {
|
||||
// URL params should still be present (enableUrlParams is false)
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage persistence', () => {
|
||||
const mockSet = set as jest.MockedFunction<typeof set>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockSet.mockClear();
|
||||
mockSet.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should read from localStorage on mount', () => {
|
||||
localStorage.setItem('test-time-key', '6h');
|
||||
|
||||
const wrapper = createWrapper({ localStoragePersistKey: 'test-time-key' });
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should write to localStorage on selectedTime change', async () => {
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-persist-key',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('12h');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith('test-persist-key', '12h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT write to localStorage when persistKey is undefined', async () => {
|
||||
const wrapper = createWrapper({ initialTime: '15m' });
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('1h');
|
||||
});
|
||||
|
||||
// Wait a tick to ensure any async operations complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedTime).toBe('1h');
|
||||
});
|
||||
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only write to localStorage when selectedTime changes, not other state', async () => {
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-key',
|
||||
initialTime: '15m',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
// Change refreshInterval (not selectedTime)
|
||||
act(() => {
|
||||
result.current.setRefreshInterval(5000);
|
||||
});
|
||||
|
||||
// Wait to ensure subscription handler had a chance to run
|
||||
await waitFor(() => {
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
// Should NOT have written to localStorage for refreshInterval change
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
|
||||
// Now change selectedTime
|
||||
act(() => {
|
||||
result.current.setSelectedTime('1h');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith('test-key', '1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should fallback to initialTime when localStorage contains empty string', () => {
|
||||
localStorage.setItem('test-key', '');
|
||||
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-key',
|
||||
initialTime: '15m',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Empty string is falsy, should use initialTime
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should write custom time range to localStorage', async () => {
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-custom-key',
|
||||
initialTime: '15m',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith('test-custom-key', customTime);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshInterval', () => {
|
||||
it('should initialize with provided refreshInterval', () => {
|
||||
const wrapper = createWrapper({ refreshInterval: 5000 });
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import {
|
||||
createGlobalTimeStore,
|
||||
defaultGlobalTimeStore,
|
||||
} from '../globalTimeStore';
|
||||
import { createCustomTimeRange } from '../utils';
|
||||
|
||||
describe('createGlobalTimeStore', () => {
|
||||
describe('factory function', () => {
|
||||
it('should create independent store instances', () => {
|
||||
const store1 = createGlobalTimeStore();
|
||||
const store2 = createGlobalTimeStore();
|
||||
|
||||
store1.getState().setSelectedTime('1h');
|
||||
|
||||
expect(store1.getState().selectedTime).toBe('1h');
|
||||
expect(store2.getState().selectedTime).toBe(DEFAULT_TIME_RANGE);
|
||||
});
|
||||
|
||||
it('should accept initial state', () => {
|
||||
const store = createGlobalTimeStore({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
|
||||
expect(store.getState().selectedTime).toBe('15m');
|
||||
expect(store.getState().refreshInterval).toBe(5000);
|
||||
expect(store.getState().isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should compute isRefreshEnabled correctly for custom time', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const store = createGlobalTimeStore({
|
||||
selectedTime: customTime,
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
|
||||
expect(store.getState().isRefreshEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultGlobalTimeStore', () => {
|
||||
it('should be a singleton', () => {
|
||||
expect(defaultGlobalTimeStore).toBeDefined();
|
||||
expect(defaultGlobalTimeStore.getState().selectedTime).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRefreshInterval', () => {
|
||||
it('should update refresh interval and enable refresh', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
|
||||
act(() => {
|
||||
store.getState().setRefreshInterval(10000);
|
||||
});
|
||||
|
||||
expect(store.getState().refreshInterval).toBe(10000);
|
||||
expect(store.getState().isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable refresh when interval is 0', () => {
|
||||
const store = createGlobalTimeStore({ refreshInterval: 5000 });
|
||||
|
||||
act(() => {
|
||||
store.getState().setRefreshInterval(0);
|
||||
});
|
||||
|
||||
expect(store.getState().refreshInterval).toBe(0);
|
||||
expect(store.getState().isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should not enable refresh for custom time range', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const store = createGlobalTimeStore({ selectedTime: customTime });
|
||||
|
||||
act(() => {
|
||||
store.getState().setRefreshInterval(10000);
|
||||
});
|
||||
|
||||
expect(store.getState().refreshInterval).toBe(10000);
|
||||
expect(store.getState().isRefreshEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store name', () => {
|
||||
it('should store name when provided', () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
expect(store.getState().name).toBe('drawer');
|
||||
});
|
||||
|
||||
it('should have undefined name when not provided', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
expect(store.getState().name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoRefreshQueryKey', () => {
|
||||
it('should generate key without name for unnamed store', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
const key = store
|
||||
.getState()
|
||||
.getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(key).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate key with name for named store', () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
const key = store
|
||||
.getState()
|
||||
.getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(key).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'drawer',
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle no query parts for named store', () => {
|
||||
const store = createGlobalTimeStore({ name: 'test' });
|
||||
const key = store.getState().getAutoRefreshQueryKey('1h');
|
||||
|
||||
expect(key).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'test',
|
||||
'1h',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle no query parts for unnamed store', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
const key = store.getState().getAutoRefreshQueryKey('1h');
|
||||
|
||||
expect(key).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import { useGlobalTimeStore } from '../globalTimeStore';
|
||||
import { GlobalTimeSelectedTime } from '../types';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
describe('globalTimeStore', () => {
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
act(() => {
|
||||
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
|
||||
});
|
||||
|
||||
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: GlobalTimeSelectedTime[] = [
|
||||
'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() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
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 * NANO_SECOND_MULTIPLIER);
|
||||
expect(second.minTime).toBe(first.minTime + 1000 * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
868
frontend/src/store/globalTime/__tests__/globalTimeStore.test.tsx
Normal file
868
frontend/src/store/globalTime/__tests__/globalTimeStore.test.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import { createGlobalTimeStore, useGlobalTimeStore } from '../globalTimeStore';
|
||||
import { GlobalTimeContext } from '../GlobalTimeContext';
|
||||
import { useGlobalTime } from '../hooks';
|
||||
import { GlobalTimeSelectedTime, GlobalTimeState } from '../types';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
/**
|
||||
* Creates an isolated store wrapper for testing.
|
||||
* Each test gets its own store instance, avoiding test pollution.
|
||||
*/
|
||||
function createIsolatedWrapper(
|
||||
initialState?: Partial<GlobalTimeState>,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
const store = createGlobalTimeStore(initialState);
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('globalTimeStore', () => {
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
act(() => {
|
||||
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('should have lastRefreshTimestamp as 0 by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.lastRefreshTimestamp).toBe(0);
|
||||
});
|
||||
|
||||
it('should have lastComputedMinMax with default values', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual({
|
||||
minTime: 0,
|
||||
maxTime: 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: GlobalTimeSelectedTime[] = [
|
||||
'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);
|
||||
});
|
||||
});
|
||||
|
||||
it('should compute and store lastComputedMinMax when selectedTime changes', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// setSelectedTime computes values on init (createIsolatedWrapper uses createGlobalTimeStore)
|
||||
// But initial store state has minTime/maxTime as 0 until first setSelectedTime is called
|
||||
const initialMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Now switch to a custom time range
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
// lastComputedMinMax should be updated to the custom range values
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual({
|
||||
minTime: 1000000000,
|
||||
maxTime: 2000000000,
|
||||
});
|
||||
expect(result.current.lastComputedMinMax).not.toStrictEqual(initialMinMax);
|
||||
});
|
||||
|
||||
it('should return fresh custom time values after switching from relative time', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Compute and cache values for relative time
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const relativeMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Switch to custom time range
|
||||
const customMinTime = 5000000000;
|
||||
const customMaxTime = 6000000000;
|
||||
const customTime = createCustomTimeRange(customMinTime, customMaxTime);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return the custom time values, not cached relative values
|
||||
const returned = result.current.getMinMaxTime();
|
||||
|
||||
expect(returned.minTime).toBe(customMinTime);
|
||||
expect(returned.maxTime).toBe(customMaxTime);
|
||||
expect(returned).not.toStrictEqual(relativeMinMax);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
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 NOT round custom time range values to minute boundaries', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
|
||||
// If rounding occurred, these would change to 12:30:00.000
|
||||
const minTimeWithSeconds =
|
||||
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeWithSeconds =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
// What the values would be if rounded down to minute boundary
|
||||
const minTimeRounded =
|
||||
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeRounded =
|
||||
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
const customTime = createCustomTimeRange(
|
||||
minTimeWithSeconds,
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
|
||||
// Should return exact values, NOT rounded values
|
||||
expect(minTime).toBe(minTimeWithSeconds);
|
||||
expect(maxTime).toBe(maxTimeWithSeconds);
|
||||
expect(minTime).not.toBe(minTimeRounded);
|
||||
expect(maxTime).not.toBe(maxTimeRounded);
|
||||
});
|
||||
|
||||
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() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(maxTime).toBe(now);
|
||||
expect(minTime).toBe(now - fifteenMinutesNs);
|
||||
});
|
||||
|
||||
it('should return same values on subsequent calls when refresh disabled (under minute boundary)', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 0); // refresh disabled
|
||||
});
|
||||
|
||||
const first = result.current.getMinMaxTime();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(59000);
|
||||
});
|
||||
|
||||
const second = result.current.getMinMaxTime();
|
||||
|
||||
// With refresh disabled, should return cached lastComputedMinMax
|
||||
expect(second.maxTime).toBe(first.maxTime);
|
||||
expect(second.minTime).toBe(first.minTime);
|
||||
});
|
||||
|
||||
it('should return different values on subsequent calls when refresh disabled after minute boundary', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 0); // refresh disabled
|
||||
});
|
||||
|
||||
const first = result.current.getMinMaxTime();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Without refresh enabled, getMinMaxTime returns cached values
|
||||
// Need to call computeAndStoreMinMax to get new values
|
||||
const second = result.current.getMinMaxTime();
|
||||
expect(second.maxTime).toBe(first.maxTime);
|
||||
|
||||
// After computing, values should update
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const third = result.current.getMinMaxTime();
|
||||
expect(third.maxTime).toBe(first.maxTime + 60000 * NANO_SECOND_MULTIPLIER);
|
||||
expect(third.minTime).toBe(first.minTime + 60000 * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
|
||||
it('should return stored lastComputedMinMax when available', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const stored = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Advance time by 5 seconds
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return stored values, not fresh computation
|
||||
const returned = result.current.getMinMaxTime();
|
||||
expect(returned).toStrictEqual(stored);
|
||||
});
|
||||
|
||||
describe('with isRefreshEnabled (isolated store)', () => {
|
||||
it('should compute fresh values when isRefreshEnabled is true (5s rounding)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// getMinMaxTime computes 5s-rounded values when refresh enabled
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time by 5 seconds to cross 5s boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return fresh values, not cached
|
||||
const freshValues = result.current.getMinMaxTime();
|
||||
|
||||
expect(freshValues.maxTime).toBe(
|
||||
initialMinMax.maxTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
expect(freshValues.minTime).toBe(
|
||||
initialMinMax.minTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update lastComputedMinMax when values change (5s rounding)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values (uses 5s rounding when refresh enabled)
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time by 5 seconds to cross 5s boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// Call getMinMaxTime - should update lastComputedMinMax
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(
|
||||
initialMinMax.maxTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(
|
||||
initialMinMax.minTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp when values change', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = result.current.lastRefreshTimestamp;
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Call getMinMaxTime - should update timestamp
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBeGreaterThan(
|
||||
initialTimestamp,
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT update lastComputedMinMax when values have not changed (same 5s window)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values (triggers computation for 5s-rounded values)
|
||||
result.current.getMinMaxTime();
|
||||
|
||||
const initialMinMax = { ...result.current.lastComputedMinMax };
|
||||
const initialTimestamp = result.current.lastRefreshTimestamp;
|
||||
|
||||
// Advance time but stay within same 5-second window
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
|
||||
// Call getMinMaxTime - should NOT update store (same 5s boundary)
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
// Values should be unchanged (no unnecessary re-renders)
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual(initialMinMax);
|
||||
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
it('should return cached values when isRefreshEnabled is false', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 0, // Refresh disabled
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const storedMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return cached values since refresh is disabled
|
||||
const returned = result.current.getMinMaxTime();
|
||||
|
||||
expect(returned).toStrictEqual(storedMinMax);
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual(storedMinMax);
|
||||
});
|
||||
|
||||
it('should return same values for custom time range regardless of time passing', () => {
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: customTime,
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// isRefreshEnabled should be false for custom time ranges
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
|
||||
// Custom time ranges always return the fixed values, not relative to "now"
|
||||
const first = result.current.getMinMaxTime();
|
||||
expect(first.minTime).toBe(minTime);
|
||||
expect(first.maxTime).toBe(maxTime);
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Should still return the same fixed values (custom range doesn't drift)
|
||||
const second = result.current.getMinMaxTime();
|
||||
expect(second.minTime).toBe(minTime);
|
||||
expect(second.maxTime).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should handle multiple consecutive refetch intervals correctly (5s rounding)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Simulate 3 refetch intervals crossing 5-second boundaries
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(
|
||||
initialMinMax.maxTime + i * 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAndStoreMinMax', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should compute and store min/max values', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// maxTime should be the current time (no rounding when refresh disabled)
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(
|
||||
expectedMaxTime - fifteenMinutesNs,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const beforeTimestamp = Date.now();
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBeGreaterThanOrEqual(
|
||||
beforeTimestamp,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the computed values', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
expect(returnedValue).toStrictEqual(result.current.lastComputedMinMax);
|
||||
});
|
||||
|
||||
it('should NOT round custom time range values to minute boundaries', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
|
||||
// If rounding occurred, these would change to 12:30:00.000
|
||||
const minTimeWithSeconds =
|
||||
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeWithSeconds =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
// What the values would be if rounded down to minute boundary
|
||||
const minTimeRounded =
|
||||
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeRounded =
|
||||
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
const customTime = createCustomTimeRange(
|
||||
minTimeWithSeconds,
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// Should return exact values, NOT rounded values
|
||||
expect(returnedValue?.minTime).toBe(minTimeWithSeconds);
|
||||
expect(returnedValue?.maxTime).toBe(maxTimeWithSeconds);
|
||||
expect(returnedValue?.minTime).not.toBe(minTimeRounded);
|
||||
expect(returnedValue?.maxTime).not.toBe(maxTimeRounded);
|
||||
|
||||
// lastComputedMinMax should also have exact values
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(minTimeWithSeconds);
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTimeWithSeconds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRefreshTimestamp', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp to current time', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateRefreshTimestamp();
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
|
||||
});
|
||||
|
||||
it('should not modify lastComputedMinMax', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const beforeMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
result.current.updateRefreshTimestamp();
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual(beforeMinMax);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedTime (min/max computation)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should compute and store min/max for relative time on setSelectedTime', () => {
|
||||
const wrapper = createIsolatedWrapper();
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Initial state has 0 values
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
// Should have computed values immediately
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:00:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(
|
||||
expectedMaxTime - fifteenMinutesNs,
|
||||
);
|
||||
});
|
||||
|
||||
it('should compute and store min/max for custom time on setSelectedTime', () => {
|
||||
const wrapper = createIsolatedWrapper();
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(minTime);
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp on setSelectedTime', () => {
|
||||
const wrapper = createIsolatedWrapper();
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
|
||||
});
|
||||
|
||||
it('should skip update when same selectedTime and refreshInterval', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Set initial values
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
const initialTimestamp = result.current.lastRefreshTimestamp;
|
||||
|
||||
// Advance time
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// Try to set same values again
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
// Should not have updated timestamp (no state change)
|
||||
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAndStoreMinMax (refresh behavior)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should skip computation and return lastComputedMinMax when refresh is enabled', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values via getMinMaxTime (which computes for refresh enabled)
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// computeAndStoreMinMax should skip computation when refresh is enabled
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// Should return the current lastComputedMinMax, not fresh computation
|
||||
expect(returnedValue).toStrictEqual(initialMinMax);
|
||||
});
|
||||
|
||||
it('should compute fresh values when refresh is disabled', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 0, // Disabled
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
const initialMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// computeAndStoreMinMax should compute fresh values
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// Should return new values
|
||||
expect(returnedValue?.maxTime).toBe(
|
||||
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
190
frontend/src/store/globalTime/__tests__/hooks.test.tsx
Normal file
190
frontend/src/store/globalTime/__tests__/hooks.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { createGlobalTimeStore } from '../globalTimeStore';
|
||||
import { GlobalTimeContext } from '../GlobalTimeContext';
|
||||
import {
|
||||
useGlobalTime,
|
||||
useGlobalTimeStoreApi,
|
||||
useIsCustomTimeRange,
|
||||
useLastComputedMinMax,
|
||||
} from '../hooks';
|
||||
import { useComputedMinMaxSync } from '../useComputedMinMaxSync';
|
||||
import { createCustomTimeRange } from '../utils';
|
||||
|
||||
describe('useGlobalTime', () => {
|
||||
it('should return full store state without selector', () => {
|
||||
const { result } = renderHook(() => useGlobalTime());
|
||||
|
||||
expect(result.current.selectedTime).toBeDefined();
|
||||
expect(result.current.setSelectedTime).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should return selected value with selector', () => {
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime));
|
||||
|
||||
expect(typeof result.current).toBe('string');
|
||||
});
|
||||
|
||||
it('should use context store when provided', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '1h' });
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useIsCustomTimeRange', () => {
|
||||
it('should return false for relative time', () => {
|
||||
const { result } = renderHook(() => useIsCustomTimeRange());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for custom time range', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: customTime });
|
||||
|
||||
const { result } = renderHook(() => useIsCustomTimeRange(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGlobalTimeStoreApi', () => {
|
||||
it('should return store API', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStoreApi());
|
||||
|
||||
expect(result.current.getState).toBeInstanceOf(Function);
|
||||
expect(result.current.subscribe).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLastComputedMinMax', () => {
|
||||
it('should return lastComputedMinMax from store', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
// Compute the min/max first
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
|
||||
const { result } = renderHook(() => useLastComputedMinMax(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current).toStrictEqual(
|
||||
contextStore.getState().lastComputedMinMax,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update when store changes', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
|
||||
const { result } = renderHook(() => useLastComputedMinMax(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
),
|
||||
});
|
||||
|
||||
const firstValue = { ...result.current };
|
||||
|
||||
// Change time and recompute
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000); // Advance 1 minute
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
expect(result.current).not.toStrictEqual(firstValue);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useComputedMinMaxSync', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should compute min/max on mount when store has zero values', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
expect(contextStore.getState().lastComputedMinMax).toStrictEqual({
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
renderHook(() => useComputedMinMaxSync(contextStore));
|
||||
|
||||
// Should have computed values now
|
||||
expect(contextStore.getState().lastComputedMinMax.maxTime).toBeGreaterThan(0);
|
||||
expect(contextStore.getState().lastComputedMinMax.minTime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should NOT recompute when store already has values', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
const initialMinMax = { ...contextStore.getState().lastComputedMinMax };
|
||||
const initialTimestamp = contextStore.getState().lastRefreshTimestamp;
|
||||
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
renderHook(() => useComputedMinMaxSync(contextStore));
|
||||
|
||||
// Should NOT have recomputed - values should be unchanged
|
||||
expect(contextStore.getState().lastComputedMinMax).toStrictEqual(
|
||||
initialMinMax,
|
||||
);
|
||||
expect(contextStore.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
it('should only compute on mount, not on re-renders', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
const { rerender } = renderHook(() => useComputedMinMaxSync(contextStore));
|
||||
|
||||
const afterMountMinMax = { ...contextStore.getState().lastComputedMinMax };
|
||||
const afterMountTimestamp = contextStore.getState().lastRefreshTimestamp;
|
||||
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
rerender();
|
||||
|
||||
// Should NOT have recomputed on re-render
|
||||
expect(contextStore.getState().lastComputedMinMax).toStrictEqual(
|
||||
afterMountMinMax,
|
||||
);
|
||||
expect(contextStore.getState().lastRefreshTimestamp).toBe(
|
||||
afterMountTimestamp,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { GlobalTimeProvider } from '../GlobalTimeContext';
|
||||
import { useGlobalTime } from '../hooks';
|
||||
import { GlobalTimeProviderOptions } from '../types';
|
||||
import { useGlobalTimeQueryInvalidate } from '../useGlobalTimeQueryInvalidate';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: Infinity,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createWrapper = (
|
||||
providerProps: GlobalTimeProviderOptions,
|
||||
queryClient: QueryClient,
|
||||
) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('useGlobalTimeQueryInvalidate', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = createTestQueryClient();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('should return a function', () => {
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(typeof result.current).toBe('function');
|
||||
});
|
||||
|
||||
it('should call computeAndStoreMinMax before invalidating queries (refresh disabled)', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ initialTime: '15m', refreshInterval: 0 }, // refresh disabled so computeAndStoreMinMax computes fresh values
|
||||
queryClient,
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
invalidate: useGlobalTimeQueryInvalidate(),
|
||||
globalTime: useGlobalTime(),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Initial computation - need to call computeAndStoreMinMax first
|
||||
act(() => {
|
||||
result.current.globalTime.computeAndStoreMinMax();
|
||||
});
|
||||
const initialMinMax = { ...result.current.globalTime.lastComputedMinMax };
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Call invalidate - should compute fresh values when refresh is disabled
|
||||
await act(async () => {
|
||||
await result.current.invalidate();
|
||||
});
|
||||
|
||||
// lastComputedMinMax should have been updated
|
||||
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
|
||||
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should invalidate queries with AUTO_REFRESH_QUERY key', async () => {
|
||||
const mockQueryFn = jest.fn().mockResolvedValue({ data: 'test' });
|
||||
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
// Set up a query with AUTO_REFRESH_QUERY key
|
||||
const { result: queryResult } = renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
|
||||
queryFn: mockQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Wait for initial query to complete
|
||||
await waitFor(() => {
|
||||
expect(queryResult.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockQueryFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Now render the invalidate hook and call it
|
||||
const { result: invalidateResult } = renderHook(
|
||||
() => useGlobalTimeQueryInvalidate(),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await invalidateResult.current();
|
||||
});
|
||||
|
||||
// Query should have been refetched
|
||||
await waitFor(() => {
|
||||
expect(mockQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT invalidate queries without AUTO_REFRESH_QUERY key', async () => {
|
||||
const autoRefreshQueryFn = jest.fn().mockResolvedValue({ data: 'auto' });
|
||||
const regularQueryFn = jest.fn().mockResolvedValue({ data: 'regular' });
|
||||
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
// Set up both types of queries
|
||||
const { result: autoRefreshQuery } = renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto-query'],
|
||||
queryFn: autoRefreshQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const { result: regularQuery } = renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: ['regular-query'],
|
||||
queryFn: regularQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Wait for initial queries to complete
|
||||
await waitFor(() => {
|
||||
expect(autoRefreshQuery.current.isSuccess).toBe(true);
|
||||
expect(regularQuery.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(1);
|
||||
expect(regularQueryFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Call invalidate
|
||||
const { result: invalidateResult } = renderHook(
|
||||
() => useGlobalTimeQueryInvalidate(),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await invalidateResult.current();
|
||||
});
|
||||
|
||||
// Only auto-refresh query should be refetched
|
||||
await waitFor(() => {
|
||||
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Regular query should NOT be refetched
|
||||
expect(regularQueryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should use exact custom time values (not rounded) when invalidating', async () => {
|
||||
// Use timestamps that are NOT on minute boundaries
|
||||
const minTimeWithSeconds =
|
||||
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeWithSeconds =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
const customTime = createCustomTimeRange(
|
||||
minTimeWithSeconds,
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
|
||||
const wrapper = createWrapper({ initialTime: customTime }, queryClient);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
invalidate: useGlobalTimeQueryInvalidate(),
|
||||
globalTime: useGlobalTime(),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Call invalidate
|
||||
await act(async () => {
|
||||
await result.current.invalidate();
|
||||
});
|
||||
|
||||
// Verify custom time values are NOT rounded
|
||||
expect(result.current.globalTime.lastComputedMinMax.minTime).toBe(
|
||||
minTimeWithSeconds,
|
||||
);
|
||||
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
});
|
||||
|
||||
it('should invalidate multiple AUTO_REFRESH_QUERY queries at once', async () => {
|
||||
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
|
||||
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
|
||||
const queryFn3 = jest.fn().mockResolvedValue({ data: 'query3' });
|
||||
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
// Set up multiple auto-refresh queries
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
|
||||
queryFn: queryFn1,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
|
||||
queryFn: queryFn2,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query3'],
|
||||
queryFn: queryFn3,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Wait for initial queries
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(1);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(1);
|
||||
expect(queryFn3).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Call invalidate
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
// All queries should be refetched
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(2);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(2);
|
||||
expect(queryFn3).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoped invalidation with store name', () => {
|
||||
it('should only invalidate queries matching store name', async () => {
|
||||
const namedQueryFn = jest.fn().mockResolvedValue({ data: 'named' });
|
||||
const unnamedQueryFn = jest.fn().mockResolvedValue({ data: 'unnamed' });
|
||||
|
||||
const wrapper = createWrapper(
|
||||
{ name: 'drawer', initialTime: '15m' },
|
||||
queryClient,
|
||||
);
|
||||
|
||||
// Query with matching name
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'named-query'],
|
||||
queryFn: namedQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Query without name (different store)
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'unnamed-query'],
|
||||
queryFn: unnamedQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(namedQueryFn).toHaveBeenCalledTimes(1);
|
||||
expect(unnamedQueryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Call invalidate
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
// Only named query should be refetched
|
||||
await waitFor(() => {
|
||||
expect(namedQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Unnamed query should NOT be refetched
|
||||
expect(unnamedQueryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should invalidate all queries for unnamed store (backward compatible)', async () => {
|
||||
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
|
||||
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
|
||||
|
||||
// Unnamed store (no name prop)
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
|
||||
queryFn: queryFn1,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
|
||||
queryFn: queryFn2,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(1);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
// Both should be refetched
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(2);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { GlobalTimeProvider } from '../GlobalTimeContext';
|
||||
import { GlobalTimeProviderOptions } from '../types';
|
||||
import { useIsGlobalTimeQueryRefreshing } from '../useIsGlobalTimeQueryRefreshing';
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createWrapper = (
|
||||
queryClient: QueryClient,
|
||||
): (({ children }: { children: ReactNode }) => JSX.Element) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const createProviderWrapper = (
|
||||
providerProps: GlobalTimeProviderOptions,
|
||||
queryClient: QueryClient,
|
||||
): (({ children }: { children: ReactNode }) => JSX.Element) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('useIsGlobalTimeQueryRefreshing', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = createTestQueryClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('should return false when no queries are fetching', () => {
|
||||
const wrapper = createWrapper(queryClient);
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when AUTO_REFRESH_QUERY is fetching', async () => {
|
||||
let resolveQuery: (value: unknown) => void;
|
||||
const queryPromise = new Promise((resolve) => {
|
||||
resolveQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start the auto-refresh query
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
|
||||
queryFn: () => queryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check if refreshing hook detects it
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true while fetching
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve the query
|
||||
act(() => {
|
||||
resolveQuery({ data: 'done' });
|
||||
});
|
||||
|
||||
// Should be false after fetching completes
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when non-AUTO_REFRESH_QUERY is fetching', async () => {
|
||||
let resolveQuery: (value: unknown) => void;
|
||||
const queryPromise = new Promise((resolve) => {
|
||||
resolveQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start a regular query (not auto-refresh)
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: ['regular-query'],
|
||||
queryFn: () => queryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check if refreshing hook detects it
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be false - not an auto-refresh query
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Cleanup
|
||||
act(() => {
|
||||
resolveQuery({ data: 'done' });
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true when multiple AUTO_REFRESH_QUERY queries are fetching', async () => {
|
||||
let resolveQuery1: (value: unknown) => void;
|
||||
let resolveQuery2: (value: unknown) => void;
|
||||
const queryPromise1 = new Promise((resolve) => {
|
||||
resolveQuery1 = resolve;
|
||||
});
|
||||
const queryPromise2 = new Promise((resolve) => {
|
||||
resolveQuery2 = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start multiple auto-refresh queries
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
|
||||
queryFn: () => queryPromise1,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
|
||||
queryFn: () => queryPromise2,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true while fetching
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve first query
|
||||
act(() => {
|
||||
resolveQuery1({ data: 'done1' });
|
||||
});
|
||||
|
||||
// Should still be true (second query still fetching)
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
// Resolve second query
|
||||
act(() => {
|
||||
resolveQuery2({ data: 'done2' });
|
||||
});
|
||||
|
||||
// Should be false after all complete
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should only track AUTO_REFRESH_QUERY, not other queries', async () => {
|
||||
let resolveAutoRefresh: (value: unknown) => void;
|
||||
let resolveRegular: (value: unknown) => void;
|
||||
const autoRefreshPromise = new Promise((resolve) => {
|
||||
resolveAutoRefresh = resolve;
|
||||
});
|
||||
const regularPromise = new Promise((resolve) => {
|
||||
resolveRegular = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start both types of queries
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto'],
|
||||
queryFn: () => autoRefreshPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: ['regular'],
|
||||
queryFn: () => regularPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true (auto-refresh is fetching)
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve auto-refresh query
|
||||
act(() => {
|
||||
resolveAutoRefresh({ data: 'done' });
|
||||
});
|
||||
|
||||
// Should be false even though regular query is still fetching
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
act(() => {
|
||||
resolveRegular({ data: 'done' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoped refreshing check with store name', () => {
|
||||
it('should return true only for queries matching store name', async () => {
|
||||
let resolveNamedQuery: (value: unknown) => void;
|
||||
const namedQueryPromise = new Promise((resolve) => {
|
||||
resolveNamedQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createProviderWrapper(
|
||||
{ name: 'drawer', initialTime: '15m' },
|
||||
queryClient,
|
||||
);
|
||||
|
||||
// Start query with matching name
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'test'],
|
||||
queryFn: () => namedQueryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check refreshing status
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true - named query is fetching
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve the query
|
||||
act(() => {
|
||||
resolveNamedQuery({ data: 'done' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when only different store queries are fetching', async () => {
|
||||
let resolveOtherQuery: (value: unknown) => void;
|
||||
const otherQueryPromise = new Promise((resolve) => {
|
||||
resolveOtherQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createProviderWrapper(
|
||||
{ name: 'drawer', initialTime: '15m' },
|
||||
queryClient,
|
||||
);
|
||||
|
||||
// Start query with different name (belongs to different store)
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'other-store', 'test'],
|
||||
queryFn: () => otherQueryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check refreshing status for 'drawer' store
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be false - the fetching query belongs to 'other-store', not 'drawer'
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Cleanup
|
||||
act(() => {
|
||||
resolveOtherQuery({ data: 'done' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { createGlobalTimeStore, GlobalTimeStoreApi } from '../globalTimeStore';
|
||||
import { useQueryCacheSync } from '../useQueryCacheSync';
|
||||
|
||||
function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createWrapper(
|
||||
queryClient: QueryClient,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('useQueryCacheSync', () => {
|
||||
let store: GlobalTimeStoreApi;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createGlobalTimeStore();
|
||||
queryClient = createTestQueryClient();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp when auto-refresh query succeeds', async () => {
|
||||
// Initialize store
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
// Advance time
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// Render the hook
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Simulate a successful auto-refresh query
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
|
||||
initialTimestamp,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update timestamp for non-auto-refresh queries', async () => {
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Simulate a regular query (not auto-refresh)
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: ['some-other-query'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
describe('store name filtering', () => {
|
||||
it('should update timestamp for named store when matching query succeeds', async () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Query with matching name
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'test'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
|
||||
initialTimestamp,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT update timestamp for named store when different name query succeeds', async () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Query with different name
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'other-store', 'test'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
it('should NOT update timestamp for named store when unnamed query succeeds', async () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Query without name (unnamed store format)
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import {
|
||||
computeRounded5sMinMax,
|
||||
createCustomTimeRange,
|
||||
CUSTOM_TIME_SEPARATOR,
|
||||
getAutoRefreshQueryKey,
|
||||
isCustomTimeRange,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
parseCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
roundDownTo5Seconds,
|
||||
} from '../utils';
|
||||
|
||||
describe('globalTime/utils', () => {
|
||||
@@ -136,4 +141,184 @@ describe('globalTime/utils', () => {
|
||||
expect(result.minTime).toBe(now - oneDayNs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundDownTo5Seconds', () => {
|
||||
it('should round down timestamp to 5-second boundary', () => {
|
||||
// 12:30:47.123Z -> 12:30:45.000Z
|
||||
const inputNano = 1705321847123 * NANO_SECOND_MULTIPLIER;
|
||||
const expectedNano = 1705321845000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
|
||||
});
|
||||
|
||||
it('should not change timestamp already at 5-second boundary', () => {
|
||||
const inputNano = 1705321845000 * NANO_SECOND_MULTIPLIER; // 12:30:45.000
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(inputNano);
|
||||
});
|
||||
|
||||
it('should round 12:30:04.999 down to 12:30:00.000', () => {
|
||||
const inputNano = 1705321804999 * NANO_SECOND_MULTIPLIER;
|
||||
const expectedNano = 1705321800000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
|
||||
});
|
||||
|
||||
it('should round 12:30:09.999 down to 12:30:05.000', () => {
|
||||
const inputNano = 1705321809999 * NANO_SECOND_MULTIPLIER;
|
||||
const expectedNano = 1705321805000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
|
||||
});
|
||||
|
||||
it('should handle timestamp at exact 5-second intervals', () => {
|
||||
// Test 5, 10, 15, 20, 25... second marks
|
||||
const base = 1705321800000; // 12:30:00
|
||||
for (let sec = 0; sec < 60; sec += 5) {
|
||||
const inputNano = (base + sec * 1000) * NANO_SECOND_MULTIPLIER;
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(inputNano);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRounded5sMinMax', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:47.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return maxTime rounded to 5-second boundary for relative time', () => {
|
||||
const result = computeRounded5sMinMax('15m');
|
||||
|
||||
// maxTime should be rounded down to 12:30:45.000
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:30:45.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
expect(result.maxTime).toBe(expectedMaxTime);
|
||||
});
|
||||
|
||||
it('should compute minTime based on 5s-rounded maxTime', () => {
|
||||
const result = computeRounded5sMinMax('15m');
|
||||
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:30:45.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(result.minTime).toBe(expectedMaxTime - fifteenMinutesNs);
|
||||
});
|
||||
|
||||
it('should return unchanged values for custom time range', () => {
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
const result = computeRounded5sMinMax(customTime);
|
||||
|
||||
expect(result.minTime).toBe(minTime);
|
||||
expect(result.maxTime).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should preserve duration for 1h relative time', () => {
|
||||
const result = computeRounded5sMinMax('1h');
|
||||
|
||||
const oneHourNs = 60 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
const duration = result.maxTime - result.minTime;
|
||||
|
||||
expect(duration).toBe(oneHourNs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoRefreshQueryKey', () => {
|
||||
it('should prefix with AUTO_REFRESH_QUERY constant', () => {
|
||||
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY');
|
||||
|
||||
expect(result[0]).toBe(REACT_QUERY_KEY.AUTO_REFRESH_QUERY);
|
||||
});
|
||||
|
||||
it('should append selectedTime at end', () => {
|
||||
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle no additional query parts', () => {
|
||||
const result = getAutoRefreshQueryKey('1h');
|
||||
|
||||
expect(result).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
|
||||
});
|
||||
|
||||
it('should handle custom time range as selectedTime', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const result = getAutoRefreshQueryKey(customTime, 'METRICS');
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'METRICS',
|
||||
customTime,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle object query parts', () => {
|
||||
const params = { entityId: '123', filter: 'active' };
|
||||
const result = getAutoRefreshQueryKey('15m', 'ENTITY', params);
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'ENTITY',
|
||||
params,
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoRefreshQueryKey deprecation', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
beforeEach(() => {
|
||||
console.warn = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
console.warn = originalWarn;
|
||||
});
|
||||
|
||||
it('should log deprecation warning in development', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
getAutoRefreshQueryKey('15m', 'TEST');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('deprecated'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT log deprecation warning in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
getAutoRefreshQueryKey('15m', 'TEST');
|
||||
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still return correct query key format', () => {
|
||||
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,144 @@
|
||||
import { createStore, StoreApi, useStore } from 'zustand';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
IGlobalTimeStoreActions,
|
||||
IGlobalTimeStoreState,
|
||||
GlobalTimeSelectedTime,
|
||||
GlobalTimeState,
|
||||
GlobalTimeStore,
|
||||
ParsedTimeRange,
|
||||
} from './types';
|
||||
import { isCustomTimeRange, parseSelectedTime } from './utils';
|
||||
import {
|
||||
computeRounded5sMinMax,
|
||||
isCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
} from './utils';
|
||||
|
||||
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
|
||||
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
|
||||
export type IGlobalTimeStore = GlobalTimeStore;
|
||||
|
||||
export const useGlobalTimeStore = create<IGlobalTimeStore>((set, get) => ({
|
||||
selectedTime: DEFAULT_TIME_RANGE,
|
||||
isRefreshEnabled: false,
|
||||
refreshInterval: 0,
|
||||
setSelectedTime: (selectedTime, refreshInterval): void => {
|
||||
set((state) => {
|
||||
const newRefreshInterval = refreshInterval ?? state.refreshInterval;
|
||||
const isCustom = isCustomTimeRange(selectedTime);
|
||||
function computeIsRefreshEnabled(
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
refreshInterval: number,
|
||||
): boolean {
|
||||
if (isCustomTimeRange(selectedTime)) {
|
||||
return false;
|
||||
}
|
||||
return refreshInterval > 0;
|
||||
}
|
||||
|
||||
return {
|
||||
selectedTime,
|
||||
refreshInterval: newRefreshInterval,
|
||||
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
getMinMaxTime: (selectedTime): ParsedTimeRange => {
|
||||
return parseSelectedTime(selectedTime || get().selectedTime);
|
||||
},
|
||||
}));
|
||||
export function createGlobalTimeStore(
|
||||
initialState?: Partial<GlobalTimeState>,
|
||||
): GlobalTimeStoreApi {
|
||||
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
|
||||
const refreshInterval = initialState?.refreshInterval ?? 0;
|
||||
const name = initialState?.name;
|
||||
|
||||
return createStore<GlobalTimeStore>((set, get) => ({
|
||||
name,
|
||||
selectedTime,
|
||||
refreshInterval,
|
||||
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
|
||||
lastRefreshTimestamp: 0,
|
||||
lastComputedMinMax: { minTime: 0, maxTime: 0 },
|
||||
|
||||
setSelectedTime: (
|
||||
time: GlobalTimeSelectedTime,
|
||||
newRefreshInterval?: number,
|
||||
): void => {
|
||||
const state = get();
|
||||
const interval = newRefreshInterval ?? state.refreshInterval;
|
||||
|
||||
if (time === state.selectedTime && interval === state.refreshInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
const computedMinMax = parseSelectedTime(time);
|
||||
|
||||
set({
|
||||
selectedTime: time,
|
||||
refreshInterval: interval,
|
||||
isRefreshEnabled: computeIsRefreshEnabled(time, interval),
|
||||
lastComputedMinMax: computedMinMax,
|
||||
lastRefreshTimestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
|
||||
setRefreshInterval: (interval: number): void => {
|
||||
set((state) => ({
|
||||
refreshInterval: interval,
|
||||
isRefreshEnabled: computeIsRefreshEnabled(state.selectedTime, interval),
|
||||
}));
|
||||
},
|
||||
|
||||
getMinMaxTime: (): ParsedTimeRange => {
|
||||
const state = get();
|
||||
|
||||
if (isCustomTimeRange(state.selectedTime)) {
|
||||
return parseSelectedTime(state.selectedTime);
|
||||
}
|
||||
|
||||
if (state.isRefreshEnabled) {
|
||||
const freshMinMax = computeRounded5sMinMax(state.selectedTime);
|
||||
|
||||
if (
|
||||
freshMinMax.minTime !== state.lastComputedMinMax.minTime ||
|
||||
freshMinMax.maxTime !== state.lastComputedMinMax.maxTime
|
||||
) {
|
||||
set({ lastComputedMinMax: freshMinMax, lastRefreshTimestamp: Date.now() });
|
||||
}
|
||||
|
||||
return freshMinMax;
|
||||
}
|
||||
|
||||
return state.lastComputedMinMax;
|
||||
},
|
||||
|
||||
computeAndStoreMinMax: (): ParsedTimeRange => {
|
||||
const state = get();
|
||||
|
||||
if (state.isRefreshEnabled) {
|
||||
return state.lastComputedMinMax;
|
||||
}
|
||||
|
||||
const computedMinMax = parseSelectedTime(state.selectedTime);
|
||||
|
||||
set({
|
||||
lastComputedMinMax: computedMinMax,
|
||||
lastRefreshTimestamp: Date.now(),
|
||||
});
|
||||
return computedMinMax;
|
||||
},
|
||||
|
||||
updateRefreshTimestamp: (): void => {
|
||||
set({ lastRefreshTimestamp: Date.now() });
|
||||
},
|
||||
|
||||
getAutoRefreshQueryKey: (
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
...queryParts: unknown[]
|
||||
): unknown[] => {
|
||||
const storeName = get().name;
|
||||
if (storeName) {
|
||||
return [
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
storeName,
|
||||
...queryParts,
|
||||
selectedTime,
|
||||
];
|
||||
}
|
||||
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export const defaultGlobalTimeStore = createGlobalTimeStore();
|
||||
|
||||
export const useGlobalTimeStore = <T = GlobalTimeStore>(
|
||||
selector?: (state: GlobalTimeStore) => T,
|
||||
): T => {
|
||||
return useStore(
|
||||
defaultGlobalTimeStore,
|
||||
selector ?? ((state) => state as unknown as T),
|
||||
);
|
||||
};
|
||||
|
||||
57
frontend/src/store/globalTime/hooks.ts
Normal file
57
frontend/src/store/globalTime/hooks.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
import { useContext } from 'react';
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import { GlobalTimeContext } from './GlobalTimeContext';
|
||||
import { defaultGlobalTimeStore, GlobalTimeStoreApi } from './globalTimeStore';
|
||||
import { GlobalTimeStore, ParsedTimeRange } from './types';
|
||||
import { isCustomTimeRange } from './utils';
|
||||
|
||||
/**
|
||||
* Access global time state with optional selector for performance.
|
||||
*
|
||||
* @example
|
||||
* // Full state (re-renders on any change)
|
||||
* const { selectedTime, setSelectedTime } = useGlobalTime();
|
||||
*
|
||||
* @example
|
||||
* // With selector (re-renders only when selectedTime changes)
|
||||
* const selectedTime = useGlobalTime(state => state.selectedTime);
|
||||
*/
|
||||
export function useGlobalTime<T = GlobalTimeStore>(
|
||||
selector?: (state: GlobalTimeStore) => T,
|
||||
equalityFn?: (a: T, b: T) => boolean,
|
||||
): T {
|
||||
const contextStore = useContext(GlobalTimeContext);
|
||||
const store = contextStore ?? defaultGlobalTimeStore;
|
||||
|
||||
return useStoreWithEqualityFn(
|
||||
store,
|
||||
selector ?? ((state) => state as unknown as T),
|
||||
equalityFn,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently using a custom time range.
|
||||
*/
|
||||
export function useIsCustomTimeRange(): boolean {
|
||||
const selectedTime = useGlobalTime((state) => state.selectedTime);
|
||||
return isCustomTimeRange(selectedTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the store API directly (for subscriptions or non-React contexts).
|
||||
*/
|
||||
export function useGlobalTimeStoreApi(): GlobalTimeStoreApi {
|
||||
const contextStore = useContext(GlobalTimeContext);
|
||||
return contextStore ?? defaultGlobalTimeStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last computed min/max time values.
|
||||
* Use this for display purposes to ensure consistency with query data.
|
||||
*/
|
||||
export function useLastComputedMinMax(): ParsedTimeRange {
|
||||
return useGlobalTime((state) => state.lastComputedMinMax);
|
||||
}
|
||||
@@ -1,9 +1,558 @@
|
||||
export { useGlobalTimeStore } from './globalTimeStore';
|
||||
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
|
||||
/**
|
||||
* # Global Time Store
|
||||
*
|
||||
* Centralized time management for the application with auto-refresh support.
|
||||
*
|
||||
* ## Quick Start
|
||||
*
|
||||
* ```tsx
|
||||
* import { useGlobalTime, NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
|
||||
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
|
||||
*
|
||||
* const { data } = useQuery({
|
||||
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY', params),
|
||||
* queryFn: () => {
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* return fetchData({ start, end });
|
||||
* },
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Core Concepts
|
||||
*
|
||||
* ### Time Formats
|
||||
*
|
||||
* | Format | Example | Description |
|
||||
* |--------|---------|-------------|
|
||||
* | Relative | `'15m'`, `'1h'`, `'1d'` | Duration from now, supports auto-refresh |
|
||||
* | Custom | `'1234567890||_||1234567899'` | Fixed range in nanoseconds, no auto-refresh |
|
||||
*
|
||||
* ### Time Units
|
||||
*
|
||||
* - Store values are in **nanoseconds**
|
||||
* - Most APIs expect **seconds**
|
||||
* - Convert to have seconds: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER / 1000)`
|
||||
* - Convert to have ms: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER)`
|
||||
*
|
||||
* ## Integration Guide
|
||||
*
|
||||
* ### Step 1: Get Store State
|
||||
*
|
||||
* Use selectors for optimal re-render performance:
|
||||
*
|
||||
* ```tsx
|
||||
* // Good - only re-renders when selectedTime changes
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
|
||||
*
|
||||
* // Avoid - re-renders on ANY store change
|
||||
* const store = useGlobalTime();
|
||||
* ```
|
||||
*
|
||||
* ### Step 2: Build Query Key
|
||||
*
|
||||
* Use the store's `getAutoRefreshQueryKey` to enable auto-refresh:
|
||||
*
|
||||
* ```tsx
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
*
|
||||
* const queryKey = useMemo(
|
||||
* () => getAutoRefreshQueryKey(
|
||||
* selectedTime, // Required - triggers invalidation
|
||||
* 'UNIQUE_KEY', // Your query identifier
|
||||
* ...otherParams // Additional cache-busting params
|
||||
* ),
|
||||
* [getAutoRefreshQueryKey, selectedTime, ...deps]
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* **Note:** For named providers (with `name` prop), query keys are automatically
|
||||
* scoped to that store, enabling isolated invalidation and refresh tracking.
|
||||
*
|
||||
* ### Step 3: Fetch Data
|
||||
*
|
||||
* **IMPORTANT**: Call `getMinMaxTime()` INSIDE `queryFn`:
|
||||
*
|
||||
* ```tsx
|
||||
* const { data } = useQuery({
|
||||
* queryKey,
|
||||
* queryFn: () => {
|
||||
* // Fresh time values computed here during auto-refresh
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* return api.fetch({ start, end });
|
||||
* },
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ### Step 4: Add Refresh Button (Optional)
|
||||
*
|
||||
* ```tsx
|
||||
* import {
|
||||
* useGlobalTimeQueryInvalidate,
|
||||
* useIsGlobalTimeQueryRefreshing,
|
||||
* } from 'store/globalTime';
|
||||
*
|
||||
* function RefreshButton() {
|
||||
* const invalidate = useGlobalTimeQueryInvalidate();
|
||||
* const isRefreshing = useIsGlobalTimeQueryRefreshing();
|
||||
*
|
||||
* return (
|
||||
* <button onClick={invalidate} disabled={isRefreshing}>
|
||||
* {isRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
* </button>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Avoiding Stale Data
|
||||
*
|
||||
* ### Problem: Time Drift During Refresh
|
||||
*
|
||||
* If multiple queries compute time independently, they may use different values:
|
||||
*
|
||||
* ```tsx
|
||||
* // BAD - each query gets different time
|
||||
* queryFn: () => {
|
||||
* const now = Date.now();
|
||||
* return fetchData({ end: now, start: now - duration });
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Solution: Use getMinMaxTime()
|
||||
*
|
||||
* `getMinMaxTime()` ensures all queries use consistent timestamps:
|
||||
* - When auto-refresh is **disabled**: returns cached values from `computeAndStoreMinMax()`
|
||||
* - When auto-refresh is **enabled**: computes fresh values (rounded to 5-second boundaries)
|
||||
*
|
||||
* Since values are rounded to 5-second boundaries, all queries calling `getMinMaxTime()`
|
||||
* within the same 5-second window get identical timestamps.
|
||||
*
|
||||
* ```tsx
|
||||
* // GOOD - all queries get same time
|
||||
* queryFn: () => {
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* return fetchData({ start: minTime, end: maxTime });
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### How It Works
|
||||
*
|
||||
* **Manual refresh:**
|
||||
* 1. User clicks refresh
|
||||
* 2. `useGlobalTimeQueryInvalidate` calls `computeAndStoreMinMax()`
|
||||
* 3. Fresh min/max stored in `lastComputedMinMax`
|
||||
* 4. All queries re-run and call `getMinMaxTime()`
|
||||
* 5. All get the SAME cached values
|
||||
*
|
||||
* **Auto-refresh (when `isRefreshEnabled = true`):**
|
||||
* 1. React-query's `refetchInterval` triggers query re-execution
|
||||
* 2. `getMinMaxTime()` computes fresh values (rounded to 5 seconds)
|
||||
* 3. If values changed, updates `lastComputedMinMax` cache
|
||||
* 4. All queries within same 5-second window get consistent values
|
||||
*
|
||||
* ## Auto-Refresh Setup
|
||||
*
|
||||
* Auto-refresh is enabled when:
|
||||
* - `selectedTime` is a relative duration (e.g., `'15m'`)
|
||||
* - `refreshInterval > 0`
|
||||
*
|
||||
* ```tsx
|
||||
* // Auto-refresh configuration
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
|
||||
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
|
||||
*
|
||||
* useQuery({
|
||||
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY'),
|
||||
* queryFn: () => { ... },
|
||||
* // Enable periodic refetch
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ## API Reference
|
||||
*
|
||||
* ### Hooks
|
||||
*
|
||||
* | Hook | Returns | Description |
|
||||
* |------|---------|-------------|
|
||||
* | `useGlobalTime(selector?)` | `T` | Access store state with optional selector |
|
||||
* | `useGlobalTimeQueryInvalidate()` | `() => Promise<void>` | Invalidate all auto-refresh queries |
|
||||
* | `useIsGlobalTimeQueryRefreshing()` | `boolean` | Check if any query is refreshing |
|
||||
* | `useIsCustomTimeRange()` | `boolean` | Check if using fixed time range |
|
||||
* | `useLastComputedMinMax()` | `ParsedTimeRange` | Get cached min/max values |
|
||||
* | `useGlobalTimeStoreApi()` | `GlobalTimeStoreApi` | Get raw store API |
|
||||
*
|
||||
* ### Store Actions
|
||||
*
|
||||
* | Action | Description |
|
||||
* |--------|-------------|
|
||||
* | `setSelectedTime(time, interval?)` | Set time range and optional refresh interval (resets cache) |
|
||||
* | `setRefreshInterval(ms)` | Set auto-refresh interval |
|
||||
* | `getMinMaxTime(time?)` | Get min/max (fresh if auto-refresh enabled, cached otherwise) |
|
||||
* | `computeAndStoreMinMax()` | Compute fresh values and cache them |
|
||||
* | `getAutoRefreshQueryKey(time, ...parts)` | Build scoped query key for this store instance |
|
||||
*
|
||||
* ### Utilities
|
||||
*
|
||||
* | Function | Description |
|
||||
* |----------|-------------|
|
||||
* | `getAutoRefreshQueryKey(time, ...parts)` | **@deprecated** Use store action instead |
|
||||
* | `parseSelectedTime(time)` | Parse time string to min/max (fresh computation) |
|
||||
* | `isCustomTimeRange(time)` | Check if time is custom range format |
|
||||
* | `createCustomTimeRange(min, max)` | Create custom range string |
|
||||
*
|
||||
* ### Constants
|
||||
*
|
||||
* | Constant | Value | Description |
|
||||
* |----------|-------|-------------|
|
||||
* | `NANO_SECOND_MULTIPLIER` | `1000000` | Convert ms to ns |
|
||||
* | `CUSTOM_TIME_SEPARATOR` | `'||_||'` | Separator in custom range strings |
|
||||
*
|
||||
* ## Context & Composition
|
||||
*
|
||||
* ### Why Use Context?
|
||||
*
|
||||
* By default, `useGlobalTime()` uses a shared global store. Use `GlobalTimeProvider`
|
||||
* to create isolated time state for specific UI sections (modals, drawers, etc.).
|
||||
*
|
||||
* ### Provider Options
|
||||
*
|
||||
* | Option | Type | Description |
|
||||
* |--------|------|-------------|
|
||||
* | `name` | `string` | Scope query keys to this store (enables isolated invalidation) |
|
||||
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value |
|
||||
* | `initialTime` | `string` | Initial time if not inheriting |
|
||||
* | `enableUrlParams` | `boolean \| object` | Sync time to URL query params |
|
||||
* | `removeQueryParamsOnUnmount` | `boolean` | Clean URL params on unmount |
|
||||
* | `localStoragePersistKey` | `string` | Persist time to localStorage |
|
||||
* | `refreshInterval` | `number` | Initial auto-refresh interval (ms) |
|
||||
*
|
||||
* ### Example 1: Isolated Time in Modal
|
||||
*
|
||||
* A modal with its own time picker that doesn't affect the main page:
|
||||
*
|
||||
* ```tsx
|
||||
* import { GlobalTimeProvider, useGlobalTime } from 'store/globalTime';
|
||||
*
|
||||
* function EntityDetailsModal({ entity, onClose }) {
|
||||
* return (
|
||||
* <Modal open onClose={onClose}>
|
||||
* // Isolated time context - changes here don't affect parent
|
||||
* <GlobalTimeProvider
|
||||
* inheritGlobalTime // Start with parent's current time
|
||||
* refreshInterval={0} // No auto-refresh in modal
|
||||
* >
|
||||
* <ModalContent entity={entity} />
|
||||
* </GlobalTimeProvider>
|
||||
* </Modal>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* function ModalContent({ entity }) {
|
||||
* // This useGlobalTime reads from the modal's isolated store
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <DateTimePicker
|
||||
* value={selectedTime}
|
||||
* onChange={(time) => setSelectedTime(time)}
|
||||
* />
|
||||
* <EntityMetrics entity={entity} />
|
||||
* <EntityLogs entity={entity} />
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 2: List Page with Detail Drawer
|
||||
*
|
||||
* Main list uses global time, drawer has independent time:
|
||||
*
|
||||
* ```tsx
|
||||
* // Main list page - uses global time (no provider needed)
|
||||
* function K8sPodsList() {
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const [selectedPod, setSelectedPod] = useState(null);
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <PageHeader>
|
||||
* <DateTimeSelectionV3 /> // Controls global time
|
||||
* </PageHeader>
|
||||
*
|
||||
* <PodsTable
|
||||
* timeRange={selectedTime}
|
||||
* onRowClick={setSelectedPod}
|
||||
* />
|
||||
*
|
||||
* {selectedPod && (
|
||||
* <PodDetailsDrawer
|
||||
* pod={selectedPod}
|
||||
* onClose={() => setSelectedPod(null)}
|
||||
* />
|
||||
* )}
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Drawer with its own time context
|
||||
* function PodDetailsDrawer({ pod, onClose }) {
|
||||
* return (
|
||||
* <Drawer open onClose={onClose}>
|
||||
* <GlobalTimeProvider
|
||||
* name="pod-drawer" // Scopes queries - only this drawer's queries are invalidated
|
||||
* inheritGlobalTime // Start with list's time
|
||||
* removeQueryParamsOnUnmount // Clean up URL when drawer closes
|
||||
* enableUrlParams={{
|
||||
* relativeTimeKey: 'drawerTime',
|
||||
* startTimeKey: 'drawerStart',
|
||||
* endTimeKey: 'drawerEnd',
|
||||
* }}
|
||||
* >
|
||||
* <DrawerHeader>
|
||||
* <DateTimeSelectionV3 /> // Controls drawer's time only
|
||||
* </DrawerHeader>
|
||||
*
|
||||
* <Tabs>
|
||||
* <Tab label="Metrics"><PodMetrics pod={pod} /></Tab>
|
||||
* <Tab label="Logs"><PodLogs pod={pod} /></Tab>
|
||||
* <Tab label="Events"><PodEvents pod={pod} /></Tab>
|
||||
* </Tabs>
|
||||
* </GlobalTimeProvider>
|
||||
* </Drawer>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 3: Nested Contexts
|
||||
*
|
||||
* Contexts can be nested - each level creates isolation:
|
||||
*
|
||||
* ```tsx
|
||||
* // App level - global time
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryClientProvider>
|
||||
* // No provider here = uses defaultGlobalTimeStore
|
||||
* <Dashboard />
|
||||
* </QueryClientProvider>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Dashboard with comparison panel
|
||||
* function Dashboard() {
|
||||
* return (
|
||||
* <div className="dashboard">
|
||||
* // Main dashboard uses global time
|
||||
* <MainCharts />
|
||||
*
|
||||
* // Comparison panel has its own time
|
||||
* <GlobalTimeProvider initialTime="1h">
|
||||
* <ComparisonPanel />
|
||||
* </GlobalTimeProvider>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* function ComparisonPanel() {
|
||||
* // This reads from ComparisonPanel's isolated store (1h)
|
||||
* // Not affected by global time changes
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* return <ComparisonCharts timeRange={selectedTime} />;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 4: URL Sync for Shareable Links
|
||||
*
|
||||
* Persist time selection to URL for shareable links:
|
||||
*
|
||||
* ```tsx
|
||||
* function TracesExplorer() {
|
||||
* return (
|
||||
* <GlobalTimeProvider
|
||||
* enableUrlParams={{
|
||||
* relativeTimeKey: 'time', // ?time=15m
|
||||
* startTimeKey: 'startTime', // ?startTime=1234567890
|
||||
* endTimeKey: 'endTime', // ?endTime=1234567899
|
||||
* }}
|
||||
* initialTime="15m" // Fallback if URL has no time params
|
||||
* >
|
||||
* <TracesContent />
|
||||
* </GlobalTimeProvider>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 5: localStorage Persistence
|
||||
*
|
||||
* Remember user's last selected time across sessions:
|
||||
*
|
||||
* ```tsx
|
||||
* function MetricsExplorer() {
|
||||
* return (
|
||||
* <GlobalTimeProvider
|
||||
* localStoragePersistKey="metrics-explorer-time"
|
||||
* initialTime="1h" // Fallback for first visit
|
||||
* >
|
||||
* <MetricsContent />
|
||||
* </GlobalTimeProvider>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Context Resolution Order
|
||||
*
|
||||
* When `useGlobalTime()` is called, it resolves the store in this order:
|
||||
*
|
||||
* 1. Nearest `GlobalTimeProvider` ancestor (if any)
|
||||
* 2. `defaultGlobalTimeStore` (global singleton)
|
||||
*
|
||||
* ```
|
||||
* App (no provider -> uses defaultGlobalTimeStore)
|
||||
* |-- Dashboard
|
||||
* |-- MainCharts (uses defaultGlobalTimeStore)
|
||||
* |-- GlobalTimeProvider (isolated store A)
|
||||
* |-- ComparisonPanel (uses store A)
|
||||
* |-- GlobalTimeProvider (isolated store B)
|
||||
* |-- NestedChart (uses store B)
|
||||
* ```
|
||||
*
|
||||
* ### Scoped Query Keys with `name`
|
||||
*
|
||||
* The `name` prop enables isolated query invalidation. When a provider has a name,
|
||||
* its queries are prefixed with that name, so invalidation only affects that store:
|
||||
*
|
||||
* ```tsx
|
||||
* // Main page - unnamed store
|
||||
* // Query keys: ['AUTO_REFRESH_QUERY', 'METRICS', ...]
|
||||
* function MainDashboard() {
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* // ...
|
||||
* }
|
||||
*
|
||||
* // Drawer - named store
|
||||
* // Query keys: ['AUTO_REFRESH_QUERY', 'drawer', 'METRICS', ...]
|
||||
* function DetailDrawer() {
|
||||
* return (
|
||||
* <GlobalTimeProvider name="drawer" inheritGlobalTime>
|
||||
* <DrawerContent />
|
||||
* </GlobalTimeProvider>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* function DrawerContent() {
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const invalidate = useGlobalTimeQueryInvalidate();
|
||||
* // invalidate() only refreshes queries with 'drawer' prefix
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Complete Example
|
||||
*
|
||||
* ```tsx
|
||||
* import { useMemo } from 'react';
|
||||
* import { useQuery } from 'react-query';
|
||||
* import { useGlobalTime, NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
*
|
||||
* function MetricsPanel({ entityId }: { entityId: string }) {
|
||||
* // 1. Get store state with selectors
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
|
||||
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
|
||||
*
|
||||
* // 2. Build query key (memoized) - automatically scoped if using named provider
|
||||
* const queryKey = useMemo(
|
||||
* () => getAutoRefreshQueryKey(selectedTime, 'METRICS', entityId),
|
||||
* [getAutoRefreshQueryKey, selectedTime, entityId]
|
||||
* );
|
||||
*
|
||||
* // 3. Query with auto-refresh
|
||||
* const { data, isLoading } = useQuery({
|
||||
* queryKey,
|
||||
* queryFn: () => {
|
||||
* // Get fresh time inside queryFn
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
*
|
||||
* return fetchMetrics({ entityId, start, end });
|
||||
* },
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
*
|
||||
* return <Chart data={data} loading={isLoading} />;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @module store/globalTime
|
||||
*/
|
||||
|
||||
// Store
|
||||
export {
|
||||
createGlobalTimeStore,
|
||||
defaultGlobalTimeStore,
|
||||
useGlobalTimeStore,
|
||||
} from './globalTimeStore';
|
||||
export type { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
// Context & Provider
|
||||
export { GlobalTimeContext, GlobalTimeProvider } from './GlobalTimeContext';
|
||||
|
||||
// Hooks
|
||||
export {
|
||||
useGlobalTime,
|
||||
useGlobalTimeStoreApi,
|
||||
useIsCustomTimeRange,
|
||||
useLastComputedMinMax,
|
||||
} from './hooks';
|
||||
|
||||
// Query hooks for auto-refresh
|
||||
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
|
||||
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
CustomTimeRange,
|
||||
CustomTimeRangeSeparator,
|
||||
GlobalTimeActions,
|
||||
GlobalTimeProviderOptions,
|
||||
GlobalTimeSelectedTime,
|
||||
GlobalTimeState,
|
||||
GlobalTimeStore,
|
||||
IGlobalTimeStoreActions,
|
||||
IGlobalTimeStoreState,
|
||||
ParsedTimeRange,
|
||||
} from './types';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
createCustomTimeRange,
|
||||
CUSTOM_TIME_SEPARATOR,
|
||||
getAutoRefreshQueryKey,
|
||||
isCustomTimeRange,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
parseCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
} from './utils';
|
||||
|
||||
// Internal hooks (for advanced use cases)
|
||||
export { useQueryCacheSync } from './useQueryCacheSync';
|
||||
|
||||
@@ -44,9 +44,80 @@ export interface IGlobalTimeStoreActions {
|
||||
) => 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.
|
||||
* Get the current min/max time values.
|
||||
* - Custom time ranges: returns exact parsed values
|
||||
* - isRefreshEnabled true: computes 5s-rounded values and updates store
|
||||
* - isRefreshEnabled false: returns lastComputedMinMax
|
||||
*/
|
||||
getMinMaxTime: (selectedItem?: GlobalTimeSelectedTime) => ParsedTimeRange;
|
||||
getMinMaxTime: () => ParsedTimeRange;
|
||||
}
|
||||
|
||||
export interface GlobalTimeProviderOptions {
|
||||
/**
|
||||
* Optional name for the store instance.
|
||||
* Used to scope query keys - only queries with this store's prefix
|
||||
* will be tracked/invalidated by this store's hooks.
|
||||
*/
|
||||
name?: string;
|
||||
/** Initialize from parent/global time */
|
||||
inheritGlobalTime?: boolean;
|
||||
/** Initial time if not inheriting */
|
||||
initialTime?: GlobalTimeSelectedTime;
|
||||
/** URL sync configuration. When false/omitted, no URL sync. */
|
||||
enableUrlParams?:
|
||||
| boolean
|
||||
| {
|
||||
relativeTimeKey?: string;
|
||||
startTimeKey?: string;
|
||||
endTimeKey?: string;
|
||||
};
|
||||
removeQueryParamsOnUnmount?: boolean;
|
||||
localStoragePersistKey?: string;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export interface GlobalTimeState {
|
||||
/**
|
||||
* Optional name for the store instance.
|
||||
* Used to scope query keys for auto-refresh queries.
|
||||
* Unnamed stores use the default prefix without a name.
|
||||
*/
|
||||
name?: string;
|
||||
selectedTime: GlobalTimeSelectedTime;
|
||||
refreshInterval: number;
|
||||
isRefreshEnabled: boolean;
|
||||
lastRefreshTimestamp: number;
|
||||
lastComputedMinMax: ParsedTimeRange;
|
||||
}
|
||||
|
||||
export interface GlobalTimeActions {
|
||||
setSelectedTime: (
|
||||
time: GlobalTimeSelectedTime,
|
||||
refreshInterval?: number,
|
||||
) => void;
|
||||
setRefreshInterval: (interval: number) => void;
|
||||
getMinMaxTime: () => ParsedTimeRange;
|
||||
/**
|
||||
* Compute fresh rounded min/max values, store them, and update refresh timestamp.
|
||||
* Call this before invalidating queries to ensure all queries use the same time values.
|
||||
*
|
||||
* @returns The newly computed ParsedTimeRange
|
||||
*/
|
||||
computeAndStoreMinMax: () => ParsedTimeRange;
|
||||
/**
|
||||
* Update the refresh timestamp to current time.
|
||||
* Called by QueryCache listener when auto-refresh queries complete.
|
||||
*/
|
||||
updateRefreshTimestamp: () => void;
|
||||
/**
|
||||
* Build query key for auto-refresh queries scoped to this store.
|
||||
* Named stores: ['AUTO_REFRESH_QUERY', name, ...parts, selectedTime]
|
||||
* Unnamed stores: ['AUTO_REFRESH_QUERY', ...parts, selectedTime]
|
||||
*/
|
||||
getAutoRefreshQueryKey: (
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
...queryParts: unknown[]
|
||||
) => unknown[];
|
||||
}
|
||||
|
||||
export type GlobalTimeStore = GlobalTimeState & GlobalTimeActions;
|
||||
|
||||
18
frontend/src/store/globalTime/useComputedMinMaxSync.ts
Normal file
18
frontend/src/store/globalTime/useComputedMinMaxSync.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
/**
|
||||
* Used to initialize computed min/max on mount when store has no values yet.
|
||||
* setSelectedTime now computes min/max on change, so subscription is no longer needed.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useComputedMinMaxSync(store: GlobalTimeStoreApi): void {
|
||||
useEffect(() => {
|
||||
const { lastComputedMinMax } = store.getState();
|
||||
if (lastComputedMinMax.minTime === 0 && lastComputedMinMax.maxTime === 0) {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
}
|
||||
}, [store]);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGlobalTime } from './hooks';
|
||||
|
||||
/**
|
||||
* Use when you want to invalidate any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
|
||||
*
|
||||
* This hook computes fresh time values before invalidating queries,
|
||||
* ensuring all queries use the same min/max time during a refresh cycle.
|
||||
*
|
||||
* For named stores, only invalidates queries matching the store's name.
|
||||
* For unnamed stores, invalidates all AUTO_REFRESH_QUERY queries.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
const computeAndStoreMinMax = useGlobalTime((s) => s.computeAndStoreMinMax);
|
||||
const name = useGlobalTime((s) => s.name);
|
||||
|
||||
return useCallback(async () => {
|
||||
// Compute fresh time values BEFORE invalidating
|
||||
// This ensures all queries that re-run will use the same time values
|
||||
// If refresh is enabled, this will just be skipped
|
||||
computeAndStoreMinMax();
|
||||
|
||||
// Build scoped query key prefix
|
||||
const queryKey = name
|
||||
? [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, name]
|
||||
: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY];
|
||||
|
||||
return await queryClient.invalidateQueries({ queryKey });
|
||||
}, [queryClient, computeAndStoreMinMax, name]);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useIsFetching } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGlobalTime } from './hooks';
|
||||
|
||||
/**
|
||||
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
|
||||
*
|
||||
* For named stores, only checks queries matching the store's name.
|
||||
* For unnamed stores, checks all AUTO_REFRESH_QUERY queries.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useIsGlobalTimeQueryRefreshing(): boolean {
|
||||
const name = useGlobalTime((s) => s.name);
|
||||
|
||||
const queryKey = name
|
||||
? [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, name]
|
||||
: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY];
|
||||
|
||||
return useIsFetching({ queryKey }) > 0;
|
||||
}
|
||||
32
frontend/src/store/globalTime/usePersistence.ts
Normal file
32
frontend/src/store/globalTime/usePersistence.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import set from 'api/browser/localstorage/set';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
/**
|
||||
* Used to keep the selected time persisted on localStorage
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function usePersistence(
|
||||
store: GlobalTimeStoreApi,
|
||||
persistKey: string | undefined,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!persistKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previousSelectedTime = store.getState().selectedTime;
|
||||
|
||||
return store.subscribe((state) => {
|
||||
if (state.selectedTime === previousSelectedTime) {
|
||||
return;
|
||||
}
|
||||
previousSelectedTime = state.selectedTime;
|
||||
|
||||
set(persistKey, state.selectedTime);
|
||||
});
|
||||
}, [store, persistKey]);
|
||||
}
|
||||
51
frontend/src/store/globalTime/useQueryCacheSync.ts
Normal file
51
frontend/src/store/globalTime/useQueryCacheSync.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
/**
|
||||
* Used to keep lastRefreshTimestamp in sync after every react query refresh.
|
||||
* For named stores, only tracks queries with matching store name.
|
||||
* For unnamed stores, tracks all AUTO_REFRESH_QUERY queries (backward compatible).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useQueryCacheSync(store: GlobalTimeStoreApi): void {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const queryCache = queryClient.getQueryCache();
|
||||
const storeName = store.getState().name;
|
||||
|
||||
return queryCache.subscribe((event) => {
|
||||
if (event?.type !== 'queryUpdated') {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = event.action as { type?: string };
|
||||
if (action?.type !== 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryKey = event.query.queryKey;
|
||||
if (!Array.isArray(queryKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// this is created by getAutoRefreshQueryKey inside the store,
|
||||
// to track usages of global time store and autoRefresh
|
||||
if (queryKey[0] !== REACT_QUERY_KEY.AUTO_REFRESH_QUERY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Named store: only track queries with matching name at position [1]
|
||||
if (storeName && queryKey[1] !== storeName) {
|
||||
return;
|
||||
}
|
||||
// Unnamed store: track all AUTO_REFRESH_QUERY queries (backward compatible)
|
||||
store.getState().updateRefreshTimestamp();
|
||||
});
|
||||
}, [queryClient, store]);
|
||||
}
|
||||
145
frontend/src/store/globalTime/useUrlSync.ts
Normal file
145
frontend/src/store/globalTime/useUrlSync.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
import { GlobalTimeProviderOptions } from './types';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
isCustomTimeRange,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
parseCustomTimeRange,
|
||||
} from './utils';
|
||||
|
||||
interface UrlSyncConfig {
|
||||
relativeTimeKey: string;
|
||||
startTimeKey: string;
|
||||
endTimeKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to sync internal state with URL when URL params are enabled.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useUrlSync(
|
||||
store: GlobalTimeStoreApi,
|
||||
enableUrlParams: GlobalTimeProviderOptions['enableUrlParams'],
|
||||
removeOnUnmount: boolean,
|
||||
): void {
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
const keys: UrlSyncConfig =
|
||||
enableUrlParams && typeof enableUrlParams === 'object'
|
||||
? {
|
||||
relativeTimeKey: enableUrlParams.relativeTimeKey ?? 'relativeTime',
|
||||
startTimeKey: enableUrlParams.startTimeKey ?? 'startTime',
|
||||
endTimeKey: enableUrlParams.endTimeKey ?? 'endTime',
|
||||
}
|
||||
: {
|
||||
relativeTimeKey: 'relativeTime',
|
||||
startTimeKey: 'startTime',
|
||||
endTimeKey: 'endTime',
|
||||
};
|
||||
|
||||
const [urlState, setUrlState] = useQueryStates(
|
||||
{
|
||||
[keys.relativeTimeKey]: parseAsString,
|
||||
[keys.startTimeKey]: parseAsInteger,
|
||||
[keys.endTimeKey]: parseAsInteger,
|
||||
},
|
||||
{ history: 'replace' },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableUrlParams || !isInitialMount.current) {
|
||||
return;
|
||||
}
|
||||
isInitialMount.current = false;
|
||||
|
||||
const relativeTime = urlState[keys.relativeTimeKey];
|
||||
const startTime = urlState[keys.startTimeKey];
|
||||
const endTime = urlState[keys.endTimeKey];
|
||||
|
||||
if (typeof startTime === 'number' && typeof endTime === 'number') {
|
||||
const customTime = createCustomTimeRange(
|
||||
startTime * NANO_SECOND_MULTIPLIER,
|
||||
endTime * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
store.getState().setSelectedTime(customTime);
|
||||
} else if (
|
||||
typeof relativeTime === 'string' &&
|
||||
isValidShortHandDateTimeFormat(relativeTime)
|
||||
) {
|
||||
store.getState().setSelectedTime(relativeTime as Time);
|
||||
}
|
||||
}, [
|
||||
urlState,
|
||||
keys?.startTimeKey,
|
||||
keys?.endTimeKey,
|
||||
keys?.relativeTimeKey,
|
||||
store,
|
||||
enableUrlParams,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableUrlParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previousSelectedTime = store.getState().selectedTime;
|
||||
|
||||
return store.subscribe((state) => {
|
||||
if (state.selectedTime === previousSelectedTime) {
|
||||
return;
|
||||
}
|
||||
previousSelectedTime = state.selectedTime;
|
||||
|
||||
if (isCustomTimeRange(state.selectedTime)) {
|
||||
const parsed = parseCustomTimeRange(state.selectedTime);
|
||||
if (parsed) {
|
||||
void setUrlState({
|
||||
[keys.relativeTimeKey]: null,
|
||||
[keys.startTimeKey]: Math.floor(parsed.minTime / NANO_SECOND_MULTIPLIER),
|
||||
[keys.endTimeKey]: Math.floor(parsed.maxTime / NANO_SECOND_MULTIPLIER),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
void setUrlState({
|
||||
[keys.relativeTimeKey]: state.selectedTime,
|
||||
[keys.startTimeKey]: null,
|
||||
[keys.endTimeKey]: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [
|
||||
store,
|
||||
keys?.startTimeKey,
|
||||
keys?.endTimeKey,
|
||||
keys?.relativeTimeKey,
|
||||
setUrlState,
|
||||
enableUrlParams,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableUrlParams || !removeOnUnmount) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
void setUrlState({
|
||||
[keys.relativeTimeKey]: null,
|
||||
[keys.startTimeKey]: null,
|
||||
[keys.endTimeKey]: null,
|
||||
});
|
||||
};
|
||||
}, [
|
||||
removeOnUnmount,
|
||||
keys?.relativeTimeKey,
|
||||
keys?.startTimeKey,
|
||||
keys?.endTimeKey,
|
||||
setUrlState,
|
||||
enableUrlParams,
|
||||
]);
|
||||
}
|
||||
@@ -44,8 +44,8 @@ export function parseCustomTimeRange(
|
||||
}
|
||||
|
||||
const [minStr, maxStr] = selectedTime.split(CUSTOM_TIME_SEPARATOR);
|
||||
const minTime = parseInt(minStr, 10);
|
||||
const maxTime = parseInt(maxStr, 10);
|
||||
const minTime = Number.parseInt(minStr, 10);
|
||||
const maxTime = Number.parseInt(maxStr, 10);
|
||||
|
||||
if (Number.isNaN(minTime) || Number.isNaN(maxTime)) {
|
||||
return null;
|
||||
@@ -79,11 +79,60 @@ export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
|
||||
}
|
||||
|
||||
/**
|
||||
* Use to build your react-query key for auto-refresh queries
|
||||
* @deprecated Use store.getAutoRefreshQueryKey() instead.
|
||||
* Access via: const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
*
|
||||
* This function only works with the default (unnamed) store prefix.
|
||||
* For named stores, use the store method to get properly scoped query keys.
|
||||
*/
|
||||
export function getAutoRefreshQueryKey(
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
...queryParts: unknown[]
|
||||
): unknown[] {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(
|
||||
'[globalTime] getAutoRefreshQueryKey from utils is deprecated. ' +
|
||||
'Use useGlobalTime((s) => s.getAutoRefreshQueryKey) instead.',
|
||||
);
|
||||
}
|
||||
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
|
||||
}
|
||||
|
||||
/**
|
||||
* Round timestamp down to the nearest 5-second boundary.
|
||||
* Used for tighter sync during auto-refresh scenarios.
|
||||
*
|
||||
* @param timestampNano - Timestamp in nanoseconds
|
||||
* @returns Timestamp rounded down to 5-second boundary in nanoseconds
|
||||
*/
|
||||
export function roundDownTo5Seconds(timestampNano: number): number {
|
||||
const msPerInterval = 5 * 1000;
|
||||
const timestampMs = Math.floor(timestampNano / NANO_SECOND_MULTIPLIER);
|
||||
const roundedMs = Math.floor(timestampMs / msPerInterval) * msPerInterval;
|
||||
return roundedMs * NANO_SECOND_MULTIPLIER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute min/max time with maxTime rounded down to 5-second boundary.
|
||||
* Used when isRefreshEnabled is true for tighter time sync.
|
||||
*
|
||||
* @param selectedTime - The selected time (relative like '15m' or custom range)
|
||||
* @returns ParsedTimeRange with 5-second rounded maxTime for relative times
|
||||
*/
|
||||
export function computeRounded5sMinMax(selectedTime: string): ParsedTimeRange {
|
||||
if (isCustomTimeRange(selectedTime)) {
|
||||
return parseSelectedTime(selectedTime);
|
||||
}
|
||||
|
||||
const nowNano = Date.now() * NANO_SECOND_MULTIPLIER;
|
||||
const roundedMaxTime = roundDownTo5Seconds(nowNano);
|
||||
|
||||
const { minTime: originalMin, maxTime: originalMax } =
|
||||
getMinMaxForSelectedTime(selectedTime as Time, 0, 0);
|
||||
const durationNano = originalMax - originalMin;
|
||||
|
||||
return {
|
||||
minTime: roundedMaxTime - durationNano,
|
||||
maxTime: roundedMaxTime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface DashboardData {
|
||||
title: string;
|
||||
layout?: Layout[];
|
||||
panelMap?: Record<string, { widgets: Layout[]; collapsed: boolean }>;
|
||||
variables: Record<string, IDashboardVariable>;
|
||||
variables?: Record<string, IDashboardVariable>;
|
||||
version?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
@@ -19146,6 +19146,11 @@ use-sidecar@^1.1.3:
|
||||
detect-node-es "^1.1.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
|
||||
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
|
||||
|
||||
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
|
||||
|
||||
// LLMCostFeature implements agentConf.AgentFeature. It reads pricing rules
|
||||
// from the module and generates the signozllmpricing processor config for
|
||||
// deployment to OTel collectors via OpAMP.
|
||||
type LLMCostFeature struct {
|
||||
module llmpricingrule.Module
|
||||
}
|
||||
|
||||
func NewLLMCostFeature(module llmpricingrule.Module) *LLMCostFeature {
|
||||
return &LLMCostFeature{module: module}
|
||||
}
|
||||
|
||||
func (f *LLMCostFeature) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
return LLMCostFeatureType
|
||||
}
|
||||
|
||||
func (f *LLMCostFeature) RecommendAgentConfig(
|
||||
orgId valuer.UUID,
|
||||
currentConfYaml []byte,
|
||||
configVersion *opamptypes.AgentConfigVersion,
|
||||
) ([]byte, string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
rules, err := f.getEnabledRules(ctx, orgId)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedConf, err := generateCollectorConfigWithLLMPricingProcessor(currentConfYaml, rules)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(rules)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return updatedConf, string(serialized), nil
|
||||
}
|
||||
|
||||
// getEnabledRules fetches all enabled pricing rules for the given org.
|
||||
func (f *LLMCostFeature) getEnabledRules(ctx context.Context, orgId valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
|
||||
if f.module == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rules, _, err := f.module.List(ctx, orgId, 0, 10000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enabled := make([]*llmpricingruletypes.LLMPricingRule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
if r.Enabled {
|
||||
enabled = append(enabled, r)
|
||||
}
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const processorName = "signozllmpricing"
|
||||
|
||||
// buildProcessorConfig converts pricing rules into the signozllmpricing processor config.
|
||||
func buildProcessorConfig(rules []*llmpricingruletypes.LLMPricingRule) *llmpricingruletypes.LLMPricingRuleProcessorConfig {
|
||||
pricingRules := make([]llmpricingruletypes.LLMPricingRuleProcessor, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
var cache llmpricingruletypes.LLMPricingRuleProcessorCache
|
||||
if r.Pricing.Cache != nil {
|
||||
cache = llmpricingruletypes.LLMPricingRuleProcessorCache{
|
||||
Mode: r.Pricing.Cache.Mode.StringValue(),
|
||||
Read: r.Pricing.Cache.Read,
|
||||
Write: r.Pricing.Cache.Write,
|
||||
}
|
||||
}
|
||||
pricingRules = append(pricingRules, llmpricingruletypes.LLMPricingRuleProcessor{
|
||||
Name: r.Model,
|
||||
Pattern: r.ModelPattern,
|
||||
Cache: cache,
|
||||
In: r.Pricing.Input,
|
||||
Out: r.Pricing.Output,
|
||||
})
|
||||
}
|
||||
|
||||
return &llmpricingruletypes.LLMPricingRuleProcessorConfig{
|
||||
Attrs: llmpricingruletypes.LLMPricingRuleProcessorAttrs{
|
||||
Model: "gen_ai.request.model",
|
||||
In: "gen_ai.usage.input_tokens",
|
||||
Out: "gen_ai.usage.output_tokens",
|
||||
CacheRead: "gen_ai.usage.cache_read.input_tokens",
|
||||
CacheWrite: "gen_ai.usage.cache_creation.input_tokens",
|
||||
},
|
||||
DefaultPricing: llmpricingruletypes.LLMPricingRuleProcessorDefaultPricing{
|
||||
Unit: "per_million_tokens",
|
||||
Rules: pricingRules,
|
||||
},
|
||||
OutputAttrs: llmpricingruletypes.LLMPricingRuleProcessorOutputAttrs{
|
||||
In: "_signoz.gen_ai.cost_input",
|
||||
Out: "_signoz.gen_ai.cost_output",
|
||||
CacheRead: "_signoz.gen_ai.cost_cache_read",
|
||||
CacheWrite: "_signoz.gen_ai.cost_cache_write",
|
||||
Total: "_signoz.gen_ai.total_cost",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// generateCollectorConfigWithLLMPricingProcessor injects (or replaces) the signozllmpricing
|
||||
// processor block in the collector YAML with one built from the given rules.
|
||||
// Pipeline wiring is handled by the collector's baseline config, not here.
|
||||
func generateCollectorConfigWithLLMPricingProcessor(
|
||||
currentConfYaml []byte,
|
||||
rules []*llmpricingruletypes.LLMPricingRule,
|
||||
) ([]byte, error) {
|
||||
// Empty input: nothing to inject into. Pass through unchanged so we don't
|
||||
// turn it into "null\n" or fail on yaml.v3's EOF.
|
||||
if len(bytes.TrimSpace(currentConfYaml)) == 0 {
|
||||
return currentConfYaml, nil
|
||||
}
|
||||
|
||||
var collectorConf map[string]any
|
||||
if err := yaml.Unmarshal(currentConfYaml, &collectorConf); err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, llmpricingruletypes.ErrCodeInvalidCollectorConfig, "failed to unmarshal collector config")
|
||||
}
|
||||
// rare but don't do anything in this case, also means it's just comments
|
||||
if collectorConf == nil {
|
||||
return currentConfYaml, nil
|
||||
}
|
||||
|
||||
processors := map[string]any{}
|
||||
if existing, ok := collectorConf["processors"]; ok && existing != nil {
|
||||
p, ok := existing.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, llmpricingruletypes.ErrCodeInvalidCollectorConfig, "collector config 'processors' must be a mapping, got %T", existing)
|
||||
}
|
||||
processors = p
|
||||
}
|
||||
|
||||
processors[processorName] = buildProcessorConfig(rules)
|
||||
collectorConf["processors"] = processors
|
||||
|
||||
out, err := yaml.Marshal(collectorConf)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, llmpricingruletypes.ErrCodeBuildPricingProcessorConf, "failed to marshal llm pricing processor config")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// assertYAMLEqualToFile decodes both sides into any and compares structurally,
|
||||
// so map key ordering is irrelevant.
|
||||
func assertYAMLEqualToFile(t *testing.T, name string, actual []byte) {
|
||||
t.Helper()
|
||||
expected, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
require.NoError(t, err)
|
||||
|
||||
var e, a any
|
||||
require.NoError(t, yaml.Unmarshal(expected, &e))
|
||||
require.NoError(t, yaml.Unmarshal(actual, &a))
|
||||
assert.Equal(t, e, a)
|
||||
}
|
||||
|
||||
func makePricingRule(model string, patterns []string, cacheMode llmpricingruletypes.LLMPricingRuleCacheMode, costIn, costOut, cacheRead, cacheWrite float64) *llmpricingruletypes.LLMPricingRule {
|
||||
return &llmpricingruletypes.LLMPricingRule{
|
||||
Model: model,
|
||||
ModelPattern: llmpricingruletypes.StringSlice(patterns),
|
||||
Unit: llmpricingruletypes.UnitPerMillionTokens,
|
||||
Pricing: llmpricingruletypes.LLMRulePricing{
|
||||
Input: costIn,
|
||||
Output: costOut,
|
||||
Cache: &llmpricingruletypes.LLMPricingCacheCosts{
|
||||
Mode: cacheMode,
|
||||
Read: cacheRead,
|
||||
Write: cacheWrite,
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCollectorConfigWithLLMPricingProcessor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rules []*llmpricingruletypes.LLMPricingRule
|
||||
expectedFile string
|
||||
}{
|
||||
{
|
||||
name: "with_rule",
|
||||
rules: []*llmpricingruletypes.LLMPricingRule{
|
||||
makePricingRule("gpt-4o", []string{"gpt-4o*"}, llmpricingruletypes.LLMPricingRuleCacheModeSubtract, 5.0, 15.0, 2.5, 0),
|
||||
},
|
||||
expectedFile: "collector_with_rule.yaml",
|
||||
},
|
||||
// We deploy the processor even with zero rules so rules can be added
|
||||
// later (by a user or by Zeus) without any config-shape change.
|
||||
// Pipeline wiring is handled by the collector's baseline config.
|
||||
{
|
||||
name: "no_rules",
|
||||
rules: nil,
|
||||
expectedFile: "collector_no_rules.yaml",
|
||||
},
|
||||
}
|
||||
|
||||
input, err := os.ReadFile(filepath.Join("testdata", "collector_baseline.yaml"))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := generateCollectorConfigWithLLMPricingProcessor(input, tc.rules)
|
||||
require.NoError(t, err)
|
||||
assertYAMLEqualToFile(t, tc.expectedFile, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCollectorConfig_EmptyInputPassthrough(t *testing.T) {
|
||||
// yaml.v3 errors on empty/whitespace input; the generator passes such
|
||||
// input through unchanged instead.
|
||||
rules := []*llmpricingruletypes.LLMPricingRule{
|
||||
makePricingRule("gpt-4o", []string{"gpt-4o*"}, llmpricingruletypes.LLMPricingRuleCacheModeSubtract, 5.0, 15.0, 2.5, 0),
|
||||
}
|
||||
|
||||
for _, in := range [][]byte{nil, []byte(" \n")} {
|
||||
out, err := generateCollectorConfigWithLLMPricingProcessor(in, rules)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, in, out)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store llmpricingruletypes.Store
|
||||
}
|
||||
|
||||
func NewModule(store llmpricingruletypes.Store) llmpricingrule.Module {
|
||||
return &module{store: store}
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
return module.store.List(ctx, orgID, offset, limit)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
return module.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
// CreateOrUpdate applies a batch of pricing rule changes:
|
||||
// - ID set → match by id, overwrite fields.
|
||||
// - SourceID set → match by source_id; if found overwrite, else insert.
|
||||
// - neither set → insert a new user-created row (is_override = true).
|
||||
//
|
||||
// When UpdatableLLMPricingRule.IsOverride is nil AND the matched row has
|
||||
// is_override = true, the row is fully preserved — only synced_at is stamped.
|
||||
func (module *module) CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []llmpricingruletypes.UpdatableLLMPricingRule) error {
|
||||
now := time.Now()
|
||||
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
for _, u := range rules {
|
||||
existing, err := module.findExisting(ctx, orgID, u)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := module.store.Create(ctx, llmpricingruletypes.NewLLMPricingRuleFromUpdatable(u, orgID, userEmail, now)); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
existing.Update(u, userEmail, now)
|
||||
if err := module.store.Update(ctx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Delete(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
if err := module.store.Delete(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findExisting returns the row matching the updatable's ID or SourceID.
|
||||
// Returns a TypeNotFound error when neither matches; the caller treats that
|
||||
// as "insert new".
|
||||
func (module *module) findExisting(ctx context.Context, orgID valuer.UUID, u llmpricingruletypes.UpdatableLLMPricingRule) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
switch {
|
||||
case u.ID != nil:
|
||||
return module.store.Get(ctx, orgID, *u.ID)
|
||||
case u.SourceID != nil:
|
||||
return module.store.GetBySourceID(ctx, orgID, *u.SourceID)
|
||||
default:
|
||||
return nil, errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "rule has neither id nor sourceId")
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) llmpricingruletypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
rules := make([]*llmpricingruletypes.LLMPricingRule, 0)
|
||||
|
||||
count, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&rules).
|
||||
Where("org_id = ?", orgID).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
ScanAndCount(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rules, count, nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
rule := new(llmpricingruletypes.LLMPricingRule)
|
||||
|
||||
err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(rule).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule %s not found", id)
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (store *store) GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
rule := new(llmpricingruletypes.LLMPricingRule)
|
||||
|
||||
err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(rule).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("source_id = ?", sourceID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule with source_id %s not found", sourceID)
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, rule *llmpricingruletypes.LLMPricingRule) error {
|
||||
_, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(rule).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, rule *llmpricingruletypes.LLMPricingRule) error {
|
||||
res, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(rule).
|
||||
Where("org_id = ?", rule.OrgID).
|
||||
Where("id = ?", rule.ID).
|
||||
ExcludeColumn("id", "org_id", "created_at", "created_by").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule %s not found", rule.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Delete(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
res, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*llmpricingruletypes.LLMPricingRule)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule %s not found", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
processors:
|
||||
signozllmpricing:
|
||||
attrs:
|
||||
model: gen_ai.request.model
|
||||
in: gen_ai.usage.input_tokens
|
||||
out: gen_ai.usage.output_tokens
|
||||
cache_read: gen_ai.usage.cache_read.input_tokens
|
||||
cache_write: gen_ai.usage.cache_creation.input_tokens
|
||||
default_pricing:
|
||||
unit: per_million_tokens
|
||||
rules: []
|
||||
output_attrs:
|
||||
in: _signoz.gen_ai.cost_input
|
||||
out: _signoz.gen_ai.cost_output
|
||||
cache_read: _signoz.gen_ai.cost_cache_read
|
||||
cache_write: _signoz.gen_ai.cost_cache_write
|
||||
total: _signoz.gen_ai.total_cost
|
||||
batch: {}
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [batch, signozllmpricing]
|
||||
exporters: [otlp]
|
||||
@@ -1,35 +0,0 @@
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
processors:
|
||||
batch: {}
|
||||
signozllmpricing:
|
||||
attrs:
|
||||
model: gen_ai.request.model
|
||||
in: gen_ai.usage.input_tokens
|
||||
out: gen_ai.usage.output_tokens
|
||||
cache_read: gen_ai.usage.cache_read.input_tokens
|
||||
cache_write: gen_ai.usage.cache_creation.input_tokens
|
||||
default_pricing:
|
||||
unit: per_million_tokens
|
||||
rules: []
|
||||
output_attrs:
|
||||
in: _signoz.gen_ai.cost_input
|
||||
out: _signoz.gen_ai.cost_output
|
||||
cache_read: _signoz.gen_ai.cost_cache_read
|
||||
cache_write: _signoz.gen_ai.cost_cache_write
|
||||
total: _signoz.gen_ai.total_cost
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc: null
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
exporters:
|
||||
- otlp
|
||||
processors:
|
||||
- batch
|
||||
- signozllmpricing
|
||||
receivers:
|
||||
- otlp
|
||||
@@ -1,44 +0,0 @@
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
processors:
|
||||
batch: {}
|
||||
signozllmpricing:
|
||||
attrs:
|
||||
model: gen_ai.request.model
|
||||
in: gen_ai.usage.input_tokens
|
||||
out: gen_ai.usage.output_tokens
|
||||
cache_read: gen_ai.usage.cache_read.input_tokens
|
||||
cache_write: gen_ai.usage.cache_creation.input_tokens
|
||||
default_pricing:
|
||||
unit: per_million_tokens
|
||||
rules:
|
||||
- name: gpt-4o
|
||||
pattern:
|
||||
- gpt-4o*
|
||||
cache:
|
||||
mode: subtract
|
||||
read: 2.5
|
||||
write: 0
|
||||
in: 5
|
||||
out: 15
|
||||
output_attrs:
|
||||
in: _signoz.gen_ai.cost_input
|
||||
out: _signoz.gen_ai.cost_output
|
||||
cache_read: _signoz.gen_ai.cost_cache_read
|
||||
cache_write: _signoz.gen_ai.cost_cache_write
|
||||
total: _signoz.gen_ai.total_cost
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc: null
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
exporters:
|
||||
- otlp
|
||||
processors:
|
||||
- batch
|
||||
- signozllmpricing
|
||||
receivers:
|
||||
- otlp
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
@@ -131,14 +130,11 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
|
||||
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
|
||||
|
||||
llmCostFeature := impllmpricingrule.NewLLMCostFeature(signoz.Modules.LLMPricingRule)
|
||||
|
||||
agentConfMgr, err := agentConf.Initiate(
|
||||
&agentConf.ManagerOptions{
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{
|
||||
logParsingPipelineController,
|
||||
llmCostFeature,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -124,6 +124,6 @@ func NewHandlers(
|
||||
AlertmanagerHandler: signozalertmanager.NewHandler(alertmanagerService),
|
||||
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
|
||||
RulerHandler: signozruler.NewHandler(rulerService),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule, providerSettings),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(nil, providerSettings),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring/implinframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
@@ -82,7 +80,6 @@ type Modules struct {
|
||||
CloudIntegration cloudintegration.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -136,6 +133,5 @@ func NewModules(
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewServiceAccountAuthzactory(sqlstore),
|
||||
sqlmigration.NewDropUserDeletedAtFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateAWSAllRegionsFactory(sqlstore),
|
||||
sqlmigration.NewAddLLMPricingRulesFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addLLMPricingRules struct {
|
||||
sqlschema sqlschema.SQLSchema
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddLLMPricingRulesFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_llm_pricing_rule"), func(_ context.Context, _ factory.ProviderSettings, _ Config) (SQLMigration, error) {
|
||||
return &addLLMPricingRules{
|
||||
sqlschema: sqlschema,
|
||||
sqlstore: sqlstore,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addLLMPricingRules) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addLLMPricingRules) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
sqls := [][]byte{}
|
||||
|
||||
tableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "llm_pricing_rule",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "created_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "updated_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "source_id", DataType: sqlschema.DataTypeText, Nullable: true},
|
||||
{Name: "model", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "provider", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "model_pattern", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "unit", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "pricing", DataType: sqlschema.DataTypeText, Nullable: false, Default: "'{}'"},
|
||||
{Name: "is_override", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "false"},
|
||||
{Name: "synced_at", DataType: sqlschema.DataTypeTimestamp, Nullable: true},
|
||||
{Name: "enabled", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "true"},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
|
||||
ColumnNames: []sqlschema.ColumnName{"id"},
|
||||
},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, tableSQLs...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Partial unique index: one Zeus-synced rule per (org, source). User-created
|
||||
// rules carry source_id = NULL and are intentionally excluded from the
|
||||
// constraint (a single org may have many).
|
||||
if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS llm_pricing_rule_org_source_unique ON llm_pricing_rule (org_id, source_id) WHERE source_id IS NOT NULL`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addLLMPricingRules) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -12,10 +12,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodePricingRuleNotFound = errors.MustNewCode("pricing_rule_not_found")
|
||||
ErrCodePricingRuleInvalidInput = errors.MustNewCode("pricing_rule_invalid_input")
|
||||
ErrCodeInvalidCollectorConfig = errors.MustNewCode("invalid_collector_config")
|
||||
ErrCodeBuildPricingProcessorConf = errors.MustNewCode("build_pricing_processor_config")
|
||||
ErrCodePricingRuleNotFound = errors.MustNewCode("pricing_rule_not_found")
|
||||
ErrCodePricingRuleInvalidInput = errors.MustNewCode("pricing_rule_invalid_input")
|
||||
)
|
||||
|
||||
type LLMPricingRuleUnit struct {
|
||||
@@ -185,48 +183,3 @@ func NewGettableLLMPricingRulesFromLLMPricingRules(items []*LLMPricingRule, tota
|
||||
Limit: limit,
|
||||
}
|
||||
}
|
||||
|
||||
func NewLLMPricingRuleFromUpdatable(u UpdatableLLMPricingRule, orgID valuer.UUID, userEmail string, now time.Time) *LLMPricingRule {
|
||||
isOverride := true
|
||||
if u.IsOverride != nil {
|
||||
isOverride = *u.IsOverride
|
||||
} else if u.SourceID != nil {
|
||||
isOverride = false
|
||||
}
|
||||
|
||||
return &LLMPricingRule{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: userEmail, UpdatedBy: userEmail},
|
||||
OrgID: orgID,
|
||||
SourceID: u.SourceID,
|
||||
Model: u.Model,
|
||||
Provider: u.Provider,
|
||||
ModelPattern: StringSlice(u.ModelPattern),
|
||||
Unit: u.Unit,
|
||||
Pricing: u.Pricing,
|
||||
IsOverride: isOverride,
|
||||
SyncedAt: &now,
|
||||
Enabled: u.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LLMPricingRule) Update(u UpdatableLLMPricingRule, userEmail string, now time.Time) {
|
||||
if u.IsOverride == nil && r.IsOverride {
|
||||
r.SyncedAt = &now
|
||||
return
|
||||
}
|
||||
|
||||
r.Model = u.Model
|
||||
r.Provider = u.Provider
|
||||
r.ModelPattern = StringSlice(u.ModelPattern)
|
||||
r.Unit = u.Unit
|
||||
r.Pricing = u.Pricing
|
||||
if u.IsOverride != nil {
|
||||
r.IsOverride = *u.IsOverride
|
||||
}
|
||||
r.Enabled = u.Enabled
|
||||
r.SyncedAt = &now
|
||||
r.UpdatedAt = now
|
||||
r.UpdatedBy = userEmail
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package llmpricingruletypes
|
||||
|
||||
// LLMPricingRuleProcessorConfig is the top-level config for the signozllmpricing
|
||||
// OTel processor that gets deployed to collectors via OpAMP.
|
||||
type LLMPricingRuleProcessorConfig struct {
|
||||
Attrs LLMPricingRuleProcessorAttrs `yaml:"attrs" json:"attrs"`
|
||||
DefaultPricing LLMPricingRuleProcessorDefaultPricing `yaml:"default_pricing" json:"default_pricing"`
|
||||
OutputAttrs LLMPricingRuleProcessorOutputAttrs `yaml:"output_attrs" json:"output_attrs"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorAttrs maps span attribute names to the processor's input fields.
|
||||
type LLMPricingRuleProcessorAttrs struct {
|
||||
Model string `yaml:"model" json:"model"`
|
||||
In string `yaml:"in" json:"in"`
|
||||
Out string `yaml:"out" json:"out"`
|
||||
CacheRead string `yaml:"cache_read" json:"cache_read"`
|
||||
CacheWrite string `yaml:"cache_write" json:"cache_write"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorDefaultPricing holds the pricing unit and the list of model-specific rules.
|
||||
type LLMPricingRuleProcessorDefaultPricing struct {
|
||||
Unit string `yaml:"unit" json:"unit"`
|
||||
Rules []LLMPricingRuleProcessor `yaml:"rules" json:"rules"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessor is a single pricing rule inside the processor config.
|
||||
type LLMPricingRuleProcessor struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Pattern []string `yaml:"pattern" json:"pattern"`
|
||||
Cache LLMPricingRuleProcessorCache `yaml:"cache" json:"cache"`
|
||||
In float64 `yaml:"in" json:"in"`
|
||||
Out float64 `yaml:"out" json:"out"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorCache describes how cached tokens are accounted for.
|
||||
type LLMPricingRuleProcessorCache struct {
|
||||
Mode string `yaml:"mode" json:"mode"`
|
||||
Read float64 `yaml:"read" json:"read"`
|
||||
Write float64 `yaml:"write" json:"write"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorOutputAttrs maps the processor's computed cost fields to span attribute names.
|
||||
type LLMPricingRuleProcessorOutputAttrs struct {
|
||||
In string `yaml:"in" json:"in"`
|
||||
Out string `yaml:"out" json:"out"`
|
||||
CacheRead string `yaml:"cache_read" json:"cache_read"`
|
||||
CacheWrite string `yaml:"cache_write" json:"cache_write"`
|
||||
Total string `yaml:"total" json:"total"`
|
||||
}
|
||||
@@ -7,11 +7,10 @@ import (
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*LLMPricingRule, int, error)
|
||||
Get(ctx context.Context, orgID, id valuer.UUID) (*LLMPricingRule, error)
|
||||
GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*LLMPricingRule, error)
|
||||
Create(ctx context.Context, rule *LLMPricingRule) error
|
||||
Update(ctx context.Context, rule *LLMPricingRule) error
|
||||
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*StorableLLMPricingRule, int, error)
|
||||
Get(ctx context.Context, orgID, id valuer.UUID) (*StorableLLMPricingRule, error)
|
||||
GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*StorableLLMPricingRule, error)
|
||||
Create(ctx context.Context, rule *StorableLLMPricingRule) error
|
||||
Update(ctx context.Context, rule *StorableLLMPricingRule) error
|
||||
Delete(ctx context.Context, orgID, id valuer.UUID) error
|
||||
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user