Compare commits
4 Commits
enhancemen
...
feat/deplo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee4a3e2e2 | ||
|
|
c0336001f5 | ||
|
|
141380f1c7 | ||
|
|
7ba6a56115 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -106,7 +106,6 @@ downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
!frontend/src/lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
||||
@@ -32,10 +32,6 @@ linters-settings:
|
||||
iface:
|
||||
enable:
|
||||
- identical
|
||||
forbidigo:
|
||||
forbid:
|
||||
- fmt.Errorf
|
||||
- ^(fmt\.Print.*|print|println)$
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- "pkg/query-service"
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.99.0
|
||||
image: signoz/signoz:v0.98.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.99.0
|
||||
image: signoz/signoz:v0.98.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
connectors:
|
||||
signozmeter:
|
||||
metrics_flush_interval: 1h
|
||||
dimensions:
|
||||
- name: service.name
|
||||
- name: deployment.environment
|
||||
- name: host.name
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
@@ -28,10 +21,6 @@ processors:
|
||||
send_batch_size: 10000
|
||||
send_batch_max_size: 11000
|
||||
timeout: 10s
|
||||
batch/meter:
|
||||
send_batch_max_size: 25000
|
||||
send_batch_size: 20000
|
||||
timeout: 1s
|
||||
resourcedetection:
|
||||
# Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels.
|
||||
detectors: [env, system]
|
||||
@@ -77,11 +66,6 @@ exporters:
|
||||
dsn: tcp://clickhouse:9000/signoz_logs
|
||||
timeout: 10s
|
||||
use_new_schema: true
|
||||
signozclickhousemeter:
|
||||
dsn: tcp://clickhouse:9000/signoz_meter
|
||||
timeout: 45s
|
||||
sending_queue:
|
||||
enabled: false
|
||||
service:
|
||||
telemetry:
|
||||
logs:
|
||||
@@ -93,20 +77,16 @@ service:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces, signozmeter]
|
||||
exporters: [clickhousetraces]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
exporters: [signozclickhousemetrics]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
exporters: [signozclickhousemetrics]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhouselogsexporter, signozmeter]
|
||||
metrics/meter:
|
||||
receivers: [signozmeter]
|
||||
processors: [batch/meter]
|
||||
exporters: [signozclickhousemeter]
|
||||
exporters: [clickhouselogsexporter]
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.99.0}
|
||||
image: signoz/signoz:${VERSION:-v0.98.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.99.0}
|
||||
image: signoz/signoz:${VERSION:-v0.98.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
connectors:
|
||||
signozmeter:
|
||||
metrics_flush_interval: 1h
|
||||
dimensions:
|
||||
- name: service.name
|
||||
- name: deployment.environment
|
||||
- name: host.name
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
@@ -28,10 +21,6 @@ processors:
|
||||
send_batch_size: 10000
|
||||
send_batch_max_size: 11000
|
||||
timeout: 10s
|
||||
batch/meter:
|
||||
send_batch_max_size: 25000
|
||||
send_batch_size: 20000
|
||||
timeout: 1s
|
||||
resourcedetection:
|
||||
# Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels.
|
||||
detectors: [env, system]
|
||||
@@ -77,11 +66,6 @@ exporters:
|
||||
dsn: tcp://clickhouse:9000/signoz_logs
|
||||
timeout: 10s
|
||||
use_new_schema: true
|
||||
signozclickhousemeter:
|
||||
dsn: tcp://clickhouse:9000/signoz_meter
|
||||
timeout: 45s
|
||||
sending_queue:
|
||||
enabled: false
|
||||
service:
|
||||
telemetry:
|
||||
logs:
|
||||
@@ -93,20 +77,16 @@ service:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces, signozmeter]
|
||||
exporters: [clickhousetraces]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
exporters: [signozclickhousemetrics]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
exporters: [signozclickhousemetrics]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhouselogsexporter, signozmeter]
|
||||
metrics/meter:
|
||||
receivers: [signozmeter]
|
||||
processors: [batch/meter]
|
||||
exporters: [signozclickhousemeter]
|
||||
exporters: [clickhouselogsexporter]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ func Config(pollInterval time.Duration, failureThreshold int) licensing.Config {
|
||||
once.Do(func() {
|
||||
config = licensing.Config{PollInterval: pollInterval, FailureThreshold: failureThreshold}
|
||||
if err := config.Validate(); err != nil {
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid licensing config"))
|
||||
panic(fmt.Errorf("invalid licensing config: %w", err))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package zeus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
)
|
||||
|
||||
@@ -24,17 +24,17 @@ func Config() zeus.Config {
|
||||
once.Do(func() {
|
||||
parsedURL, err := neturl.Parse(url)
|
||||
if err != nil {
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid zeus URL"))
|
||||
panic(fmt.Errorf("invalid zeus URL: %w", err))
|
||||
}
|
||||
|
||||
deprecatedParsedURL, err := neturl.Parse(deprecatedURL)
|
||||
if err != nil {
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid zeus deprecated URL"))
|
||||
panic(fmt.Errorf("invalid zeus deprecated URL: %w", err))
|
||||
}
|
||||
|
||||
config = zeus.Config{URL: parsedURL, DeprecatedURL: deprecatedParsedURL}
|
||||
if err := config.Validate(); err != nil {
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid zeus config"))
|
||||
panic(fmt.Errorf("invalid zeus config: %w", err))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
|
||||
export { isEventObject } from '../src/utils/isEventObject';
|
||||
|
||||
interface SafeNavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { IUser } from 'providers/App/types';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
|
||||
import { MarkersProvider } from 'providers/Markers/Markers';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
@@ -379,30 +380,34 @@ function App(): JSX.Element {
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
<DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AlertRuleProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</AlertRuleProvider>
|
||||
</KeyboardHotkeysProvider>
|
||||
</DashboardProvider>
|
||||
<MarkersProvider>
|
||||
<DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AlertRuleProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense
|
||||
fallback={<Spinner size="large" tip="Loading..." />}
|
||||
>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</AlertRuleProvider>
|
||||
</KeyboardHotkeysProvider>
|
||||
</DashboardProvider>
|
||||
</MarkersProvider>
|
||||
</QueryBuilderProvider>
|
||||
</ResourceProvider>
|
||||
</PrivateRoute>
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface UpdateMetricMetadataProps {
|
||||
metricType: MetricType;
|
||||
temporality?: Temporality;
|
||||
isMonotonic?: boolean;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMetricMetadataResponse {
|
||||
|
||||
@@ -8,7 +8,7 @@ const setRetentionV2 = async ({
|
||||
type,
|
||||
defaultTTLDays,
|
||||
coldStorageVolume,
|
||||
coldStorageDurationDays,
|
||||
coldStorageDuration,
|
||||
ttlConditions,
|
||||
}: PropsV2): Promise<SuccessResponseV2<PayloadPropsV2>> => {
|
||||
try {
|
||||
@@ -16,7 +16,7 @@ const setRetentionV2 = async ({
|
||||
type,
|
||||
defaultTTLDays,
|
||||
coldStorageVolume,
|
||||
coldStorageDurationDays,
|
||||
coldStorageDuration,
|
||||
ttlConditions,
|
||||
});
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ export const RawLogViewContainer = styled(Row)<{
|
||||
transition: background-color 2s ease-in;`
|
||||
: ''}
|
||||
|
||||
${({ $isCustomHighlighted }): string =>
|
||||
getCustomHighlightBackground($isCustomHighlighted)}
|
||||
${({ $isCustomHighlighted, $isDarkMode, $logType }): string =>
|
||||
getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)}
|
||||
`;
|
||||
|
||||
export const InfoIconWrapper = styled(Info)`
|
||||
|
||||
@@ -153,9 +153,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
children: (
|
||||
<TableBodyContent
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(field as string, {
|
||||
shouldEscapeHtml: true,
|
||||
}),
|
||||
__html: getSanitizedLogBody(field as string),
|
||||
}}
|
||||
fontSize={fontSize}
|
||||
linesPerRow={linesPerRow}
|
||||
|
||||
@@ -32,7 +32,6 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
ALL_SELECTED_VALUE,
|
||||
filterOptionsBySearch,
|
||||
handleScrollToBottom,
|
||||
prioritizeOrAddOptionForMultiSelect,
|
||||
@@ -44,6 +43,8 @@ enum ToggleTagValue {
|
||||
All = 'All',
|
||||
}
|
||||
|
||||
const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
|
||||
|
||||
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
placeholder = 'Search...',
|
||||
className,
|
||||
|
||||
@@ -5,8 +5,6 @@ import { OptionData } from './types';
|
||||
|
||||
export const SPACEKEY = ' ';
|
||||
|
||||
export const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
|
||||
|
||||
export const prioritizeOrAddOptionForSingleSelect = (
|
||||
options: OptionData[],
|
||||
value: string,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
.panel-markers-control {
|
||||
padding: 16px;
|
||||
.panel-markers-view-controller {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markers-control-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 8px;
|
||||
.ant-skeleton-input{
|
||||
width: 230px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-markers-inputs-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 8px;
|
||||
.panel-markers-select-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
.custom-multiselect-wrapper {
|
||||
width: fit-content;
|
||||
min-width: 230px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.variable-name {
|
||||
display: flex;
|
||||
min-width: 56px;
|
||||
height: 32px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--bg-robin-300);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
|
||||
.info-icon {
|
||||
margin-left: 4px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
3
frontend/src/components/PanelMarkersControl/constants.ts
Normal file
3
frontend/src/components/PanelMarkersControl/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const MARKER_TYPES = {
|
||||
DEPLOYMENT: 'deployment',
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Dispatch } from 'react';
|
||||
import { useReducer } from 'react';
|
||||
|
||||
import type { MarkerControlState, MarkerQueryState } from '../types';
|
||||
|
||||
export const MARKER_ACTIONS = {
|
||||
TOGGLE_SHOW_MARKERS: 'toggleShowMarkers',
|
||||
SET_MARKER_SERVICES: 'setMarkerServices',
|
||||
SET_MARKER_TYPES: 'setMarkerTypes',
|
||||
SET_DEFAULTS_ON: 'setDefaultsOn',
|
||||
RESET: 'reset',
|
||||
} as const;
|
||||
|
||||
export type MarkerActionType = typeof MARKER_ACTIONS[keyof typeof MARKER_ACTIONS];
|
||||
|
||||
export type MarkerControlAction =
|
||||
| { type: typeof MARKER_ACTIONS.TOGGLE_SHOW_MARKERS; payload: boolean }
|
||||
| { type: typeof MARKER_ACTIONS.SET_MARKER_SERVICES; payload: string[] }
|
||||
| { type: typeof MARKER_ACTIONS.SET_MARKER_TYPES; payload: string[] }
|
||||
| {
|
||||
type: typeof MARKER_ACTIONS.SET_DEFAULTS_ON;
|
||||
payload: { markerServices: string[]; markerTypes: string[] };
|
||||
}
|
||||
| { type: typeof MARKER_ACTIONS.RESET };
|
||||
|
||||
function normalizeInitialState(
|
||||
state: MarkerQueryState | null,
|
||||
): MarkerControlState {
|
||||
return {
|
||||
showMarkers: state?.showMarkers ? 1 : 0,
|
||||
markerServices: state?.markerServices || [],
|
||||
markerTypes: state?.markerTypes || [],
|
||||
};
|
||||
}
|
||||
|
||||
function reducer(
|
||||
state: MarkerControlState,
|
||||
action: MarkerControlAction,
|
||||
): MarkerControlState {
|
||||
switch (action.type) {
|
||||
case MARKER_ACTIONS.TOGGLE_SHOW_MARKERS:
|
||||
return { ...state, showMarkers: action.payload ? 1 : 0 };
|
||||
case MARKER_ACTIONS.SET_MARKER_SERVICES:
|
||||
return { ...state, markerServices: action.payload };
|
||||
case MARKER_ACTIONS.SET_MARKER_TYPES:
|
||||
return { ...state, markerTypes: action.payload };
|
||||
case MARKER_ACTIONS.SET_DEFAULTS_ON:
|
||||
return {
|
||||
...state,
|
||||
showMarkers: 1,
|
||||
markerServices: action.payload.markerServices,
|
||||
markerTypes: action.payload.markerTypes,
|
||||
};
|
||||
case MARKER_ACTIONS.RESET:
|
||||
return { showMarkers: 0, markerServices: [], markerTypes: [] };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function useMarkerControlState(
|
||||
initialQueryState: MarkerQueryState | null,
|
||||
): {
|
||||
store: MarkerControlState;
|
||||
dispatch: Dispatch<MarkerControlAction>;
|
||||
} {
|
||||
const initial = normalizeInitialState(initialQueryState);
|
||||
const [store, dispatch] = useReducer(reducer, initial);
|
||||
return { store, dispatch };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
clearLocalStorageState,
|
||||
getLocalStorageState,
|
||||
getMarkerStateFromQuery,
|
||||
getQueryParamsFromState,
|
||||
setLocalStorageState,
|
||||
} from 'components/PanelMarkersControl/utils';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
type MarkerHandlers = {
|
||||
onMarkerToggleOn: () => void;
|
||||
onMarkerToggleOff: () => void;
|
||||
};
|
||||
|
||||
const useMarkerHandlers = ({ key }: { key: string }): MarkerHandlers => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
// useEffect to sync url query with local storage
|
||||
useEffect(() => {
|
||||
const queryState = getMarkerStateFromQuery(urlQuery);
|
||||
const localStorageState = getLocalStorageState(key);
|
||||
|
||||
if (queryState === null && localStorageState?.showMarkers) {
|
||||
const params = new URLSearchParams(search);
|
||||
const queryParams = getQueryParamsFromState(params, localStorageState);
|
||||
history.replace({ search: queryParams.toString() });
|
||||
} else {
|
||||
setLocalStorageState(key, queryState);
|
||||
}
|
||||
}, [urlQuery, key, search, history]);
|
||||
|
||||
const onMarkerToggleOn = useCallback(() => {
|
||||
// set defaults for service and marker type
|
||||
const params = new URLSearchParams(search);
|
||||
params.set('showMarkers', '1');
|
||||
history.replace({ search: params.toString() });
|
||||
}, [search, history]);
|
||||
|
||||
const onMarkerToggleOff = useCallback(() => {
|
||||
// important to clear both url query and local storage here. Else url local storage sync useEffect will not work as expected.
|
||||
clearLocalStorageState(key);
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
params.delete('showMarkers');
|
||||
params.delete('markerServices');
|
||||
params.delete('markerTypes');
|
||||
history.replace({ search: params.toString() });
|
||||
}, [key, search, history]);
|
||||
|
||||
return { onMarkerToggleOn, onMarkerToggleOff };
|
||||
};
|
||||
|
||||
export default useMarkerHandlers;
|
||||
234
frontend/src/components/PanelMarkersControl/index.tsx
Normal file
234
frontend/src/components/PanelMarkersControl/index.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import './PanelMarkersControl.scss';
|
||||
|
||||
import { Skeleton, Switch, Typography } from 'antd';
|
||||
import CustomMultiSelect from 'components/NewSelect/CustomMultiSelect';
|
||||
import { MARKER_TYPES } from 'components/PanelMarkersControl/constants';
|
||||
import useMarkerControlState, {
|
||||
MARKER_ACTIONS,
|
||||
} from 'components/PanelMarkersControl/hooks/useMarkerControlState';
|
||||
import useMarkerHandlers from 'components/PanelMarkersControl/hooks/useMarkerHandlers';
|
||||
import {
|
||||
getInitialStateForControls,
|
||||
getQueryParamsFromState,
|
||||
} from 'components/PanelMarkersControl/utils';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useFetchMarkersData, useMarkers } from 'providers/Markers/Markers';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
function PanelMarkersControl(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const { selectedDashboard } = useDashboard();
|
||||
|
||||
const { store: markerControlState, dispatch } = useMarkerControlState(
|
||||
getInitialStateForControls(selectedDashboard?.id || '', urlQuery),
|
||||
);
|
||||
|
||||
const { markersData, setMarkersData } = useMarkers();
|
||||
|
||||
const { loadingMarkers } = useFetchMarkersData({
|
||||
isFetchEnabled: markerControlState.showMarkers === 1,
|
||||
});
|
||||
|
||||
const { onMarkerToggleOn, onMarkerToggleOff } = useMarkerHandlers({
|
||||
key: selectedDashboard?.id || '',
|
||||
});
|
||||
|
||||
// API integration: check if this is correct
|
||||
const markerTypeOptions = useMemo(() => {
|
||||
const uniqueTypes = Array.from(
|
||||
new Set((markersData || []).map((m: any) => m?.type).filter(Boolean)),
|
||||
);
|
||||
return uniqueTypes.map((t: string) => ({
|
||||
label: t.charAt(0).toUpperCase() + t.slice(1),
|
||||
value: t,
|
||||
}));
|
||||
}, [markersData]);
|
||||
|
||||
// API integration: check if this is correct
|
||||
const serviceNameOptions = useMemo(() => {
|
||||
const uniqueServices = Array.from(
|
||||
new Set(
|
||||
(markersData || [])
|
||||
.map((m: any) => m?.attr?.['service.name'])
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
return uniqueServices.map((s: string) => ({ label: s, value: s }));
|
||||
}, [markersData]);
|
||||
|
||||
const handleServiceChange = useCallback(
|
||||
(serviceOrServices: string | string[] | undefined): void => {
|
||||
let servicesArray: string[] = [];
|
||||
if (Array.isArray(serviceOrServices)) {
|
||||
servicesArray = serviceOrServices;
|
||||
} else if (serviceOrServices) {
|
||||
servicesArray = [serviceOrServices];
|
||||
}
|
||||
dispatch({
|
||||
type: MARKER_ACTIONS.SET_MARKER_SERVICES,
|
||||
payload: servicesArray,
|
||||
});
|
||||
|
||||
// sync URL param
|
||||
const params = new URLSearchParams(search);
|
||||
if (servicesArray.length > 0) {
|
||||
params.set('markerServices', servicesArray.join(','));
|
||||
} else {
|
||||
params.delete('markerServices');
|
||||
}
|
||||
history.replace({ search: params.toString() });
|
||||
},
|
||||
[history, search, dispatch],
|
||||
);
|
||||
|
||||
const handleMarkerTypesChange = useCallback(
|
||||
(typesOrArray: string | string[] | undefined): void => {
|
||||
let typesArray: string[] = [];
|
||||
if (Array.isArray(typesOrArray)) {
|
||||
typesArray = typesOrArray;
|
||||
} else if (typesOrArray) {
|
||||
typesArray = [typesOrArray];
|
||||
}
|
||||
dispatch({ type: MARKER_ACTIONS.SET_MARKER_TYPES, payload: typesArray });
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
if (typesArray.length > 0) {
|
||||
params.set('markerTypes', typesArray.join(','));
|
||||
} else {
|
||||
params.delete('markerTypes');
|
||||
}
|
||||
history.replace({ search: params.toString() });
|
||||
},
|
||||
[history, search, dispatch],
|
||||
);
|
||||
|
||||
const handleToggleShowMarkers = useCallback(
|
||||
(checked: boolean): void => {
|
||||
dispatch({ type: MARKER_ACTIONS.TOGGLE_SHOW_MARKERS, payload: checked });
|
||||
if (checked) {
|
||||
// get default services and marker types from markersData
|
||||
onMarkerToggleOn();
|
||||
} else {
|
||||
// consider using useReducer to reset the state
|
||||
setMarkersData([]);
|
||||
dispatch({ type: MARKER_ACTIONS.RESET });
|
||||
onMarkerToggleOff();
|
||||
}
|
||||
},
|
||||
[onMarkerToggleOn, onMarkerToggleOff, dispatch, setMarkersData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (markersData.length < 1) return;
|
||||
/**
|
||||
* On markers data change, derive defaults (or use query selections if present)
|
||||
* and set them in a single reducer dispatch.
|
||||
*/
|
||||
const queryMarkerServicesRaw = urlQuery.get('markerServices') || '';
|
||||
const queryMarkerTypesRaw = urlQuery.get('markerTypes') || '';
|
||||
|
||||
const defMarkerServices = ['cart-service'];
|
||||
const defMarkerTypes = [MARKER_TYPES.DEPLOYMENT];
|
||||
|
||||
const servicesArray = queryMarkerServicesRaw
|
||||
? queryMarkerServicesRaw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
: defMarkerServices;
|
||||
const typesArray = queryMarkerTypesRaw
|
||||
? queryMarkerTypesRaw
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0)
|
||||
: defMarkerTypes;
|
||||
|
||||
dispatch({
|
||||
type: MARKER_ACTIONS.SET_DEFAULTS_ON,
|
||||
payload: { markerServices: servicesArray, markerTypes: typesArray },
|
||||
});
|
||||
|
||||
// reflect in URL params as well
|
||||
const params = new URLSearchParams(search);
|
||||
const queryParams = getQueryParamsFromState(params, {
|
||||
showMarkers: 1,
|
||||
markerServices: servicesArray,
|
||||
markerTypes: typesArray,
|
||||
});
|
||||
history.replace({ search: queryParams.toString() });
|
||||
|
||||
console.log('>>> markersData', markersData);
|
||||
// urlQuery removed from dependencies as not able to unset markerTypes. But works with markerServices. [CHECK THIS]
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [markersData]);
|
||||
|
||||
// ADD INITIAL STATE FOR SELECTED SERVICES AND MARKER TYPES
|
||||
|
||||
return (
|
||||
<div className="panel-markers-control">
|
||||
<div className="panel-markers-view-controller">
|
||||
<Typography>Show Markers</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={markerControlState.showMarkers === 1}
|
||||
onChange={handleToggleShowMarkers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{markerControlState.showMarkers === 1 && (
|
||||
<div className="panel-markers-inputs-section">
|
||||
{loadingMarkers ? (
|
||||
<div className="markers-control-skeleton">
|
||||
<Skeleton.Input active size="small" />
|
||||
<Skeleton.Input active size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-markers-select-container">
|
||||
<Typography.Text className="variable-name" ellipsis>
|
||||
Marker type
|
||||
</Typography.Text>
|
||||
<CustomMultiSelect
|
||||
className="panel-markers-select"
|
||||
placeholder="Select one or more marker types"
|
||||
enableAllSelection={false}
|
||||
options={markerTypeOptions}
|
||||
maxTagCount={3}
|
||||
value={markerControlState.markerTypes}
|
||||
onChange={handleMarkerTypesChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="panel-markers-select-container">
|
||||
<Typography.Text className="variable-name" ellipsis>
|
||||
Service name
|
||||
</Typography.Text>
|
||||
<CustomMultiSelect
|
||||
className="panel-markers-select"
|
||||
placeholder="Select one or more service names"
|
||||
maxTagCount={3}
|
||||
enableAllSelection={false}
|
||||
options={serviceNameOptions}
|
||||
value={markerControlState.markerServices}
|
||||
onChange={handleServiceChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// select bright color for the markers
|
||||
// convert panel marker state to useReducer
|
||||
// removed urlQuery from dependencies.
|
||||
// filters on markersData should work properly. If no markers selected. Show no markers.
|
||||
|
||||
export default PanelMarkersControl;
|
||||
7
frontend/src/components/PanelMarkersControl/types.ts
Normal file
7
frontend/src/components/PanelMarkersControl/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface MarkerControlState {
|
||||
showMarkers: number;
|
||||
markerServices: string[];
|
||||
markerTypes: string[];
|
||||
}
|
||||
|
||||
export type MarkerQueryState = MarkerControlState | null;
|
||||
108
frontend/src/components/PanelMarkersControl/utils.ts
Normal file
108
frontend/src/components/PanelMarkersControl/utils.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { MarkerQueryState } from 'components/PanelMarkersControl/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
// CHECK LOGIC AND CREATE UTIL
|
||||
export function getMarkerStateFromQuery(
|
||||
urlQuery: URLSearchParams,
|
||||
): MarkerQueryState | null {
|
||||
const showMarkers = urlQuery.get('showMarkers') === '1';
|
||||
const servicesRaw = urlQuery.get('markerServices') || '';
|
||||
|
||||
if (!showMarkers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const markerTypesParam = urlQuery.get('markerTypes') || '';
|
||||
|
||||
const markerTypes = markerTypesParam
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
const markerServices = servicesRaw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
return {
|
||||
showMarkers: 1,
|
||||
markerServices,
|
||||
markerTypes,
|
||||
};
|
||||
}
|
||||
|
||||
export const getLocalStorageState = (key: string): MarkerQueryState | null => {
|
||||
const raw = localStorage.getItem(LOCALSTORAGE.MARKERS_OVERLAY_STATE);
|
||||
try {
|
||||
const parsed = raw ? JSON.parse(raw) : null;
|
||||
return parsed?.[key] ?? null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getQueryParamsFromState = (
|
||||
params: URLSearchParams,
|
||||
state: MarkerQueryState,
|
||||
): URLSearchParams => {
|
||||
if (!state) {
|
||||
return params;
|
||||
}
|
||||
if (state.showMarkers) {
|
||||
params.set('showMarkers', String(state.showMarkers) || '0');
|
||||
}
|
||||
if (Array.isArray(state.markerServices) && state.markerServices.length > 0) {
|
||||
params.set('markerServices', state.markerServices.join(','));
|
||||
}
|
||||
if (Array.isArray(state.markerTypes) && state.markerTypes.length > 0) {
|
||||
params.set('markerTypes', state.markerTypes.join(','));
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
export const setLocalStorageState = (
|
||||
key: string,
|
||||
state: MarkerQueryState | null,
|
||||
): void => {
|
||||
if (!key || key.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCALSTORAGE.MARKERS_OVERLAY_STATE);
|
||||
let obj: Record<string, unknown> = {};
|
||||
try {
|
||||
obj = raw ? JSON.parse(raw) : {};
|
||||
} catch (_) {
|
||||
obj = {};
|
||||
}
|
||||
obj[key] = state;
|
||||
localStorage.setItem(LOCALSTORAGE.MARKERS_OVERLAY_STATE, JSON.stringify(obj));
|
||||
} catch (_) {
|
||||
// ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
export const getInitialStateForControls = (
|
||||
key: string,
|
||||
urlQuery: URLSearchParams,
|
||||
): MarkerQueryState | null => {
|
||||
const queryState = getMarkerStateFromQuery(urlQuery);
|
||||
const localStorageState = getLocalStorageState(key);
|
||||
return queryState ?? localStorageState;
|
||||
};
|
||||
|
||||
export const clearLocalStorageState = (key: string): void => {
|
||||
if (!key || key.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
const raw = localStorage.getItem(LOCALSTORAGE.MARKERS_OVERLAY_STATE);
|
||||
let obj: Record<string, unknown> = {};
|
||||
try {
|
||||
obj = raw ? JSON.parse(raw) : {};
|
||||
} catch (_) {
|
||||
obj = {};
|
||||
}
|
||||
delete obj[key];
|
||||
localStorage.setItem(LOCALSTORAGE.MARKERS_OVERLAY_STATE, JSON.stringify(obj));
|
||||
};
|
||||
@@ -2,8 +2,7 @@ import './MetricsSelect.styles.scss';
|
||||
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { memo } from 'react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const MetricsSelect = memo(function MetricsSelect({
|
||||
@@ -17,28 +16,19 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
version: string;
|
||||
signalSource: 'meter' | '';
|
||||
}): JSX.Element {
|
||||
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
|
||||
|
||||
const { handleChangeAggregatorAttribute } = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
entityVersion: version,
|
||||
});
|
||||
|
||||
const handleAggregatorAttributeChange = useCallback(
|
||||
(value: BaseAutocompleteData, isEditMode?: boolean) => {
|
||||
handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []);
|
||||
},
|
||||
[handleChangeAggregatorAttribute, attributeKeys],
|
||||
);
|
||||
return (
|
||||
<div className="metrics-select-container">
|
||||
<AggregatorFilter
|
||||
onChange={handleAggregatorAttributeChange}
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
query={query}
|
||||
index={index}
|
||||
signalSource={signalSource || ''}
|
||||
setAttributeKeys={setAttributeKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -500,10 +500,7 @@ function QueryAddOns({
|
||||
}
|
||||
value={addOn}
|
||||
>
|
||||
<div
|
||||
className="add-on-tab-title"
|
||||
data-testid={`query-add-on-${addOn.key}`}
|
||||
>
|
||||
<div className="add-on-tab-title">
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</div>
|
||||
|
||||
@@ -45,12 +45,6 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.filter-separator {
|
||||
height: 1px;
|
||||
background-color: var(--bg-slate-400);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -183,12 +177,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.values {
|
||||
.filter-separator {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { FiltersType, QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import { quickFiltersAttributeValuesResponse } from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import CheckboxFilter from './Checkbox';
|
||||
|
||||
// Mock the query builder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
const mockUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
|
||||
// Mock the aggregate values hook
|
||||
jest.mock('hooks/queryBuilder/useGetAggregateValues');
|
||||
|
||||
const mockUseGetAggregateValues = jest.mocked(useGetAggregateValues);
|
||||
|
||||
// Mock the key value suggestions hook
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeyValueSuggestions');
|
||||
|
||||
const mockUseGetQueryKeyValueSuggestions = jest.mocked(
|
||||
useGetQueryKeyValueSuggestions,
|
||||
);
|
||||
|
||||
interface MockFilterConfig {
|
||||
title: string;
|
||||
attributeKey: {
|
||||
key: string;
|
||||
dataType: DataTypes;
|
||||
type: string;
|
||||
};
|
||||
dataSource: DataSource;
|
||||
defaultOpen: boolean;
|
||||
type: FiltersType;
|
||||
}
|
||||
|
||||
const createMockFilter = (
|
||||
overrides: Partial<MockFilterConfig> = {},
|
||||
): MockFilterConfig => ({
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
title: 'Service Name',
|
||||
attributeKey: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
dataSource: DataSource.LOGS,
|
||||
defaultOpen: false,
|
||||
type: FiltersType.CHECKBOX,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockQueryBuilderData = (hasActiveFilters = false): any => ({
|
||||
lastUsedQuery: 0,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: hasActiveFilters
|
||||
? [
|
||||
{
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['otel-demo', 'sample-flask'],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
|
||||
describe('CheckboxFilter - User Flows', () => {
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default mock implementations using the same structure as existing tests
|
||||
mockUseGetAggregateValues.mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
stringAttributeValues: [
|
||||
'mq-kafka',
|
||||
'otel-demo',
|
||||
'otlp-python',
|
||||
'sample-flask',
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as UseQueryResult<SuccessResponse<IAttributeValuesResponse>>);
|
||||
|
||||
mockUseGetQueryKeyValueSuggestions.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
// Setup MSW server for API calls
|
||||
server.use(
|
||||
rest.get('*/api/v3/autocomplete/attribute_values', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should auto-open filter and prioritize checked items with visual separator when user opens page with active filters', async () => {
|
||||
// Mock query builder with active filters
|
||||
mockUseQueryBuilder.mockReturnValue(createMockQueryBuilderData(true) as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: false });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// User should see the filter is automatically opened (not collapsed)
|
||||
expect(screen.getByText('Service Name')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// User should see visual separator between checked and unchecked items
|
||||
expect(screen.getByTestId('filter-separator')).toBeInTheDocument();
|
||||
|
||||
// User should see checked items at the top
|
||||
await waitFor(() => {
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(4); // Ensure we have exactly 4 checkboxes
|
||||
expect(checkboxes[0]).toBeChecked(); // otel-demo should be first and checked
|
||||
expect(checkboxes[1]).toBeChecked(); // sample-flask should be second and checked
|
||||
expect(checkboxes[2]).not.toBeChecked(); // mq-kafka should be unchecked
|
||||
expect(checkboxes[3]).not.toBeChecked(); // otlp-python should be unchecked
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect user preference when user manually toggles filter over auto-open behavior', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Mock query builder with active filters
|
||||
mockUseQueryBuilder.mockReturnValue(createMockQueryBuilderData(true) as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: false });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Initially auto-opened due to active filters
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// User manually closes the filter
|
||||
await user.click(screen.getByText('Service Name'));
|
||||
|
||||
// User should see filter is now closed (respecting user preference)
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Filter values'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// User manually opens the filter again
|
||||
await user.click(screen.getByText('Service Name'));
|
||||
|
||||
// User should see filter is now open (respecting user preference)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQue
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -54,8 +54,7 @@ interface ICheckboxProps {
|
||||
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const { source, filter, onFilterChange } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
// null = no user action, true = user opened, false = user closed
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
const [isOpen, setIsOpen] = useState<boolean>(filter.defaultOpen);
|
||||
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
|
||||
|
||||
const {
|
||||
@@ -64,33 +63,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
// Check if this filter has active filters in the query
|
||||
const isSomeFilterPresentForCurrentAttribute = useMemo(
|
||||
() =>
|
||||
currentQuery.builder.queryData?.[
|
||||
lastUsedQuery || 0
|
||||
]?.filters?.items?.some((item) =>
|
||||
isEqual(item.key?.key, filter.attributeKey.key),
|
||||
),
|
||||
[currentQuery.builder.queryData, lastUsedQuery, filter.attributeKey.key],
|
||||
);
|
||||
|
||||
// Derive isOpen from filter state + user action
|
||||
const isOpen = useMemo(() => {
|
||||
// If user explicitly toggled, respect that
|
||||
if (userToggleState !== null) return userToggleState;
|
||||
|
||||
// Auto-open if this filter has active filters in the query
|
||||
if (isSomeFilterPresentForCurrentAttribute) return true;
|
||||
|
||||
// Otherwise use default behavior (first 2 filters open)
|
||||
return filter.defaultOpen;
|
||||
}, [
|
||||
userToggleState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
filter.defaultOpen,
|
||||
]);
|
||||
|
||||
const { data, isLoading } = useGetAggregateValues(
|
||||
{
|
||||
aggregateOperator: filter.aggregateOperator || 'noop',
|
||||
@@ -156,6 +128,8 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
|
||||
const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount);
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
@@ -228,23 +202,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
// Sort checked items to the top, then unchecked items
|
||||
const currentAttributeKeys = useMemo(() => {
|
||||
const checkedValues = attributeValues.filter(
|
||||
(val) => currentFilterState[val],
|
||||
);
|
||||
const uncheckedValues = attributeValues.filter(
|
||||
(val) => !currentFilterState[val],
|
||||
);
|
||||
return [...checkedValues, ...uncheckedValues].slice(0, visibleItemsCount);
|
||||
}, [attributeValues, currentFilterState, visibleItemsCount]);
|
||||
|
||||
// Count of checked values in the currently visible items
|
||||
const checkedValuesCount = useMemo(
|
||||
() => currentAttributeKeys.filter((val) => currentFilterState[val]).length,
|
||||
[currentAttributeKeys, currentFilterState],
|
||||
);
|
||||
|
||||
const handleClearFilterAttribute = (): void => {
|
||||
const preparedQuery: Query = {
|
||||
...currentQuery,
|
||||
@@ -278,6 +235,12 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const isSomeFilterPresentForCurrentAttribute = currentQuery.builder.queryData?.[
|
||||
lastUsedQuery || 0
|
||||
]?.filters?.items?.some((item) =>
|
||||
isEqual(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
const onChange = (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
@@ -527,10 +490,10 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
className="filter-header-checkbox"
|
||||
onClick={(): void => {
|
||||
if (isOpen) {
|
||||
setUserToggleState(false);
|
||||
setIsOpen(false);
|
||||
setVisibleItemsCount(10);
|
||||
} else {
|
||||
setUserToggleState(true);
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -577,59 +540,50 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
)}
|
||||
{attributeValues.length > 0 ? (
|
||||
<section className="values">
|
||||
{currentAttributeKeys.map((value: string, index: number) => (
|
||||
<Fragment key={value}>
|
||||
{index === checkedValuesCount && checkedValuesCount > 0 && (
|
||||
<div
|
||||
key="separator"
|
||||
className="filter-separator"
|
||||
data-testid="filter-separator"
|
||||
/>
|
||||
)}
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
onChange={(e): void => onChange(value, e.target.checked, false)}
|
||||
checked={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
rootClassName="check-box"
|
||||
/>
|
||||
{currentAttributeKeys.map((value: string) => (
|
||||
<div key={value} className="value">
|
||||
<Checkbox
|
||||
onChange={(e): void => onChange(value, e.target.checked, false)}
|
||||
checked={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
rootClassName="check-box"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'checkbox-value-section',
|
||||
isFilterDisabled ? 'filter-disabled' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
if (isFilterDisabled) {
|
||||
return;
|
||||
}
|
||||
onChange(value, currentFilterState[value], true);
|
||||
}}
|
||||
>
|
||||
<div className={`${filter.title} label-${value}`} />
|
||||
{filter.customRendererForValue ? (
|
||||
filter.customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text
|
||||
className="value-string"
|
||||
ellipsis={{ tooltip: { placement: 'right' } }}
|
||||
>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
'checkbox-value-section',
|
||||
isFilterDisabled ? 'filter-disabled' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
if (isFilterDisabled) {
|
||||
return;
|
||||
}
|
||||
onChange(value, currentFilterState[value], true);
|
||||
}}
|
||||
>
|
||||
<div className={`${filter.title} label-${value}`} />
|
||||
{filter.customRendererForValue ? (
|
||||
filter.customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text
|
||||
className="value-string"
|
||||
ellipsis={{ tooltip: { placement: 'right' } }}
|
||||
>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : isEmptyStateWithDocsEnabled ? (
|
||||
|
||||
177
frontend/src/components/Uplot/plugins/verticalMarkersPlugin.ts
Normal file
177
frontend/src/components/Uplot/plugins/verticalMarkersPlugin.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
|
||||
import uPlot from 'uplot';
|
||||
|
||||
type MarkersData = { id: string | number; val: number; stroke?: string };
|
||||
|
||||
export function verticalMarkersPlugin({
|
||||
markersData = [],
|
||||
lineType = [5, 3],
|
||||
width = 1,
|
||||
}: {
|
||||
markersData?: MarkersData[];
|
||||
lineType?: number[];
|
||||
width?: number;
|
||||
} = {}): uPlot.Plugin {
|
||||
const DEFAULT_STROKE = 'rgba(0, 102, 255, 0.95)';
|
||||
let removeListeners: (() => void) | null = null;
|
||||
let tooltipEl: HTMLDivElement | null = null;
|
||||
const renderAxisMarkers = (uu: uPlot): void => {
|
||||
const axes = uu.root.querySelectorAll('.u-axis');
|
||||
const xAxis = (axes && (axes[0] as HTMLElement)) || null;
|
||||
if (!xAxis) return;
|
||||
|
||||
// attach delegated hover/mouseout listeners once on the x-axis container
|
||||
if (!(xAxis as HTMLElement).dataset?.vlineHoverAttached) {
|
||||
const onMouseOver = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target?.classList?.contains('vline-triangle-marker')) return;
|
||||
|
||||
const { id } = target.dataset;
|
||||
const valStr = target.dataset.val;
|
||||
const val = valStr ? Number(valStr) : undefined;
|
||||
// const mData = markersData.find((d) => String(d.id) === String(id));
|
||||
// create tooltip
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.className = 'vline-marker-tooltip';
|
||||
Object.assign(tooltipEl.style, {
|
||||
position: 'fixed',
|
||||
padding: '6px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
zIndex: '10000',
|
||||
pointerEvents: 'none',
|
||||
background: '#111827',
|
||||
color: '#e5e7eb',
|
||||
border: '1px solid #374151',
|
||||
});
|
||||
document.body.appendChild(tooltipEl);
|
||||
}
|
||||
tooltipEl.textContent = `id: ${id ?? ''} • ts: ${val ?? ''}`;
|
||||
// position near cursor
|
||||
tooltipEl.style.left = `${e.clientX + 10}px`;
|
||||
tooltipEl.style.top = `${e.clientY - 28}px`;
|
||||
};
|
||||
const onMouseOut = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target?.classList?.contains('vline-triangle-marker')) return;
|
||||
if (tooltipEl) {
|
||||
tooltipEl.remove();
|
||||
tooltipEl = null;
|
||||
}
|
||||
};
|
||||
xAxis.addEventListener('mouseover', onMouseOver);
|
||||
xAxis.addEventListener('mouseout', onMouseOut);
|
||||
removeListeners = (): void => {
|
||||
xAxis.removeEventListener('mouseover', onMouseOver);
|
||||
xAxis.removeEventListener('mouseout', onMouseOut);
|
||||
};
|
||||
(xAxis as HTMLElement).dataset.vlineHoverAttached = '1';
|
||||
}
|
||||
|
||||
// cleanup markers to avoid duplicates on rerender/resize
|
||||
xAxis.querySelectorAll('.vline-triangle-marker').forEach((el) => el.remove());
|
||||
|
||||
const plotLeft = uu.bbox.left;
|
||||
const plotRight = plotLeft + uu.bbox.width;
|
||||
|
||||
for (let i = 0; i < markersData.length; i++) {
|
||||
const mData = markersData[i];
|
||||
const xAbs = uu.valToPos(mData.val, 'x', true);
|
||||
if (xAbs >= plotLeft && xAbs <= plotRight) {
|
||||
const xPx = (xAbs - plotLeft) / window.devicePixelRatio;
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'vline-triangle-marker';
|
||||
marker.dataset.id = String(mData.id); // may change later after BE discussion
|
||||
marker.dataset.val = String(mData.val); // TODO: remove this later
|
||||
Object.assign(marker.style, {
|
||||
position: 'absolute',
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderBottomWidth: '5px',
|
||||
borderBottomStyle: 'solid',
|
||||
borderBottomColor: mData.stroke || DEFAULT_STROKE,
|
||||
transform: 'translateX(-50%)',
|
||||
cursor: 'pointer',
|
||||
zIndex: '1',
|
||||
left: `${xPx}px`,
|
||||
});
|
||||
|
||||
xAxis.appendChild(marker);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
destroy: [
|
||||
(): void => {
|
||||
if (tooltipEl) {
|
||||
tooltipEl.remove();
|
||||
tooltipEl = null;
|
||||
}
|
||||
if (removeListeners) {
|
||||
removeListeners();
|
||||
removeListeners = null;
|
||||
}
|
||||
},
|
||||
],
|
||||
drawAxes: [
|
||||
(uu: uPlot): void => {
|
||||
renderAxisMarkers(uu);
|
||||
},
|
||||
],
|
||||
draw: [
|
||||
(uu: uPlot): void => {
|
||||
const { ctx } = uu;
|
||||
const { top } = uu.bbox;
|
||||
const bottom = top + uu.bbox.height;
|
||||
const plotLeft = uu.bbox.left;
|
||||
const plotRight = plotLeft + uu.bbox.width;
|
||||
|
||||
ctx.save();
|
||||
for (let i = 0; i < markersData.length; i++) {
|
||||
const mData = markersData[i];
|
||||
const x = uu.valToPos(mData.val, 'x', true);
|
||||
// only draw if within plot bounds
|
||||
if (x >= plotLeft && x <= plotRight) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = mData.stroke || DEFAULT_STROKE;
|
||||
ctx.lineWidth = width;
|
||||
ctx.setLineDash(lineType || []);
|
||||
ctx.moveTo(x, top);
|
||||
ctx.lineTo(x, bottom);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// MOVE TO REACT. or use portal for tooltip.
|
||||
// correct the format should work with expected data from BE
|
||||
// Remove cognitive complexity rule.
|
||||
|
||||
// logic to get marker plugin to be added to context
|
||||
// depending on type of marker parse the data to choose color. example deployment should be red etc.
|
||||
// pass such data with multple colors to check render.
|
||||
// PERF CHECK.
|
||||
|
||||
// drive data passing from the context for markers. data in this is enriched only when we
|
||||
// use <ShowMarkers /> component. else it will be empty.
|
||||
// plugins. and pass marker hooks using this
|
||||
// should only pass marker hook if data is present(shouldRenderMarker memo)
|
||||
// depending on type of marker parse the data to choose color. example deployment should be red etc.
|
||||
// pass such data with multple colors to check render.
|
||||
// PERF CHECK.
|
||||
|
||||
// drive data passing from the context for markers. data in this is enriched only when we
|
||||
// use <ShowMarkers /> component. else it will be empty.
|
||||
@@ -12,7 +12,6 @@ function YAxisUnitSelector({
|
||||
onChange,
|
||||
placeholder = 'Please select a unit',
|
||||
loading = false,
|
||||
'data-testid': dataTestId,
|
||||
}: YAxisUnitSelectorProps): JSX.Element {
|
||||
const universalUnit = mapMetricUnitToUniversalUnit(value);
|
||||
|
||||
@@ -46,7 +45,6 @@ function YAxisUnitSelector({
|
||||
placeholder={placeholder}
|
||||
filterOption={(input, option): boolean => handleSearch(input, option)}
|
||||
loading={loading}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{Y_AXIS_CATEGORIES.map((category) => (
|
||||
<Select.OptGroup key={category.name} label={category.name}>
|
||||
|
||||
@@ -4,7 +4,6 @@ export interface YAxisUnitSelectorProps {
|
||||
placeholder?: string;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export enum UniversalYAxisUnit {
|
||||
|
||||
@@ -36,4 +36,5 @@ export enum LOCALSTORAGE {
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
MARKERS_OVERLAY_STATE = 'MARKERS_OVERLAY_STATE',
|
||||
}
|
||||
|
||||
@@ -50,5 +50,4 @@ export enum QueryParams {
|
||||
tab = 'tab',
|
||||
thresholds = 'thresholds',
|
||||
selectedExplorerView = 'selectedExplorerView',
|
||||
variables = 'variables',
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@ export const REACT_QUERY_KEY = {
|
||||
SPAN_LOGS: 'SPAN_LOGS',
|
||||
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
|
||||
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
|
||||
TRACE_ONLY_LOGS: 'TRACE_ONLY_LOGS',
|
||||
|
||||
// Routing Policies Query Keys
|
||||
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',
|
||||
|
||||
@@ -183,7 +183,6 @@ function AlertThreshold({
|
||||
}}
|
||||
style={{ width: 80 }}
|
||||
options={queryNames}
|
||||
data-testid="alert-threshold-query-select"
|
||||
/>
|
||||
<Typography.Text className="sentence-text">is</Typography.Text>
|
||||
<Select
|
||||
@@ -196,7 +195,6 @@ function AlertThreshold({
|
||||
}}
|
||||
style={{ width: 180 }}
|
||||
options={THRESHOLD_OPERATOR_OPTIONS}
|
||||
data-testid="alert-threshold-operator-select"
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
the threshold(s)
|
||||
@@ -211,7 +209,6 @@ function AlertThreshold({
|
||||
}}
|
||||
style={{ width: 180 }}
|
||||
options={matchTypeOptionsWithTooltips}
|
||||
data-testid="alert-threshold-match-type-select"
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
during the <EvaluationSettings />
|
||||
@@ -239,7 +236,6 @@ function AlertThreshold({
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addThreshold}
|
||||
className="add-threshold-btn"
|
||||
data-testid="add-threshold-button"
|
||||
>
|
||||
Add Threshold
|
||||
</Button>
|
||||
|
||||
@@ -32,7 +32,6 @@ function ThresholdItem({
|
||||
style={{ width: 150 }}
|
||||
options={units}
|
||||
disabled={units.length === 0}
|
||||
data-testid="threshold-unit-select"
|
||||
/>
|
||||
);
|
||||
if (units.length === 0) {
|
||||
@@ -48,7 +47,6 @@ function ThresholdItem({
|
||||
style={{ width: 150 }}
|
||||
options={units}
|
||||
disabled={units.length === 0}
|
||||
data-testid="threshold-unit-select"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -98,7 +96,6 @@ function ThresholdItem({
|
||||
updateThreshold(threshold.id, 'label', e.target.value)
|
||||
}
|
||||
style={{ width: 200 }}
|
||||
data-testid="threshold-name-input"
|
||||
/>
|
||||
<Typography.Text className="sentence-text">on value</Typography.Text>
|
||||
<Typography.Text className="sentence-text highlighted-text">
|
||||
@@ -112,7 +109,6 @@ function ThresholdItem({
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
data-testid="threshold-value-input"
|
||||
/>
|
||||
{yAxisUnitSelect}
|
||||
{!notificationSettings.routingPolicies && (
|
||||
@@ -123,12 +119,10 @@ function ThresholdItem({
|
||||
onChange={(value): void =>
|
||||
updateThreshold(threshold.id, 'channels', value)
|
||||
}
|
||||
data-testid="threshold-notification-channel-select"
|
||||
style={{ width: 350 }}
|
||||
options={channels.map((channel) => ({
|
||||
value: channel.name,
|
||||
label: channel.name,
|
||||
'data-testid': `threshold-notification-channel-option-${threshold.label}`,
|
||||
}))}
|
||||
mode="multiple"
|
||||
placeholder="Select notification channels"
|
||||
@@ -163,7 +157,6 @@ function ThresholdItem({
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
data-testid="recovery-threshold-value-input"
|
||||
/>
|
||||
<Tooltip title="Remove recovery threshold">
|
||||
<Button
|
||||
@@ -171,7 +164,6 @@ function ThresholdItem({
|
||||
icon={<Trash size={16} />}
|
||||
onClick={removeRecoveryThreshold}
|
||||
className="icon-btn"
|
||||
data-testid="remove-recovery-threshold-button"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
@@ -195,7 +187,6 @@ function ThresholdItem({
|
||||
icon={<CircleX size={16} />}
|
||||
onClick={(): void => removeThreshold(threshold.id)}
|
||||
className="icon-btn"
|
||||
data-testid="remove-threshold-button"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -50,7 +50,6 @@ export function getCategorySelectOptionByName(
|
||||
(unit) => ({
|
||||
label: unit.name,
|
||||
value: unit.id,
|
||||
'data-testid': `threshold-unit-select-option-${unit.id}`,
|
||||
}),
|
||||
) || []
|
||||
);
|
||||
@@ -402,7 +401,6 @@ export function RoutingPolicyBanner({
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
checked={notificationSettings.routingPolicies}
|
||||
data-testid="routing-policies-switch"
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_ROUTING_POLICIES',
|
||||
|
||||
@@ -52,7 +52,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
}
|
||||
className="alert-header__input title"
|
||||
placeholder="Enter alert rule name"
|
||||
data-testid="alert-name-input"
|
||||
/>
|
||||
<LabelsInput
|
||||
labels={alertState.labels}
|
||||
|
||||
@@ -124,11 +124,7 @@ function LabelsInput({
|
||||
{Object.keys(labels).length > 0 && (
|
||||
<div className="labels-input__existing-labels">
|
||||
{Object.entries(labels).map(([key, value]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="labels-input__label-pill"
|
||||
data-testid={`label-pill-${key}-${value}`}
|
||||
>
|
||||
<span key={key} className="labels-input__label-pill">
|
||||
{key}: {value}
|
||||
<button
|
||||
type="button"
|
||||
@@ -147,7 +143,6 @@ function LabelsInput({
|
||||
className="labels-input__add-button"
|
||||
type="button"
|
||||
onClick={handleAddLabelsClick}
|
||||
data-testid="alert-add-label-button"
|
||||
>
|
||||
+ Add labels
|
||||
</button>
|
||||
@@ -163,7 +158,6 @@ function LabelsInput({
|
||||
placeholder={inputState.isKeyInput ? 'Enter key' : 'Enter value'}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
data-testid="alert-add-label-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,6 @@ function AdvancedOptionItem({
|
||||
tooltipText,
|
||||
onToggle,
|
||||
defaultShowInput,
|
||||
'data-testid': dataTestId,
|
||||
}: IAdvancedOptionItemProps): JSX.Element {
|
||||
const [showInput, setShowInput] = useState<boolean>(false);
|
||||
|
||||
@@ -27,7 +26,7 @@ function AdvancedOptionItem({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="advanced-option-item" data-testid={dataTestId}>
|
||||
<div className="advanced-option-item">
|
||||
<div className="advanced-option-item-left-content">
|
||||
<Typography.Text className="advanced-option-item-title">
|
||||
{title}
|
||||
|
||||
@@ -43,7 +43,6 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
defaultShowInput={advancedOptions.sendNotificationIfDataIsMissing.enabled}
|
||||
data-testid="send-notification-if-data-is-missing-container"
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
title="Minimum data required"
|
||||
@@ -75,7 +74,6 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
defaultShowInput={advancedOptions.enforceMinimumDatapoints.enabled}
|
||||
data-testid="enforce-minimum-datapoints-container"
|
||||
/>
|
||||
{/* TODO: Add back when the functionality is implemented */}
|
||||
{/* <AdvancedOptionItem
|
||||
|
||||
@@ -78,7 +78,6 @@ function EvaluationCadence(): JSX.Element {
|
||||
},
|
||||
})
|
||||
}
|
||||
data-testid="evaluation-cadence-duration-input"
|
||||
/>
|
||||
<Select
|
||||
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
|
||||
@@ -97,7 +96,6 @@ function EvaluationCadence(): JSX.Element {
|
||||
},
|
||||
})
|
||||
}
|
||||
data-testid="evaluation-cadence-unit-select"
|
||||
/>
|
||||
</Input.Group>
|
||||
{/* TODO: Add custom schedule back once the functionality is implemented */}
|
||||
|
||||
@@ -30,7 +30,7 @@ function EvaluationSettings(): JSX.Element {
|
||||
trigger="click"
|
||||
showArrow={false}
|
||||
>
|
||||
<Button data-testid="evaluation-settings-button">
|
||||
<Button>
|
||||
<div className="evaluate-alert-conditions-button-left">
|
||||
{getTimeframeText(evaluationWindow)}
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,6 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
data-testid="evaluation-window-details-starting-at-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,7 +154,6 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
data-testid="evaluation-window-details-timezone-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,7 +174,6 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
data-testid="evaluation-window-details-starting-at-select"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
@@ -193,7 +190,6 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
data-testid="evaluation-window-details-timezone-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,7 +211,6 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.number}
|
||||
onChange={(e): void => handleNumberChange(e.target.value)}
|
||||
placeholder="Enter value"
|
||||
data-testid="evaluation-window-details-custom-rolling-window-duration-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
@@ -225,7 +220,6 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.unit || null}
|
||||
onChange={handleUnitChange}
|
||||
placeholder="Select unit"
|
||||
data-testid="evaluation-window-details-custom-rolling-window-unit-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,7 @@ function TimeInput({
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="time-input" className={`time-input-container ${className}`}>
|
||||
<div className={`time-input-container ${className}`}>
|
||||
<Input
|
||||
data-field="hours"
|
||||
value={hours}
|
||||
@@ -156,7 +156,6 @@ function TimeInput({
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
data-testid="time-input-hours"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
@@ -169,7 +168,6 @@ function TimeInput({
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
data-testid="time-input-minutes"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
@@ -182,7 +180,6 @@ function TimeInput({
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
data-testid="time-input-seconds"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface IAdvancedOptionItemProps {
|
||||
tooltipText?: string;
|
||||
onToggle?: () => void;
|
||||
defaultShowInput: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export enum RollingWindowTimeframes {
|
||||
|
||||
@@ -24,7 +24,6 @@ function MultipleNotifications(): JSX.Element {
|
||||
return uniqueGroupBys.map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
'data-testid': 'multiple-notifications-select-option',
|
||||
}));
|
||||
}, [currentQuery.builder.queryData]);
|
||||
|
||||
@@ -50,7 +49,6 @@ function MultipleNotifications(): JSX.Element {
|
||||
disabled={!isMultipleNotificationsEnabled}
|
||||
aria-disabled={!isMultipleNotificationsEnabled}
|
||||
maxTagCount={3}
|
||||
data-testid="multiple-notifications-select"
|
||||
/>
|
||||
{isMultipleNotificationsEnabled && (
|
||||
<Typography.Paragraph className="multiple-notifications-select-description">
|
||||
|
||||
@@ -37,7 +37,6 @@ function NotificationSettings(): JSX.Element {
|
||||
},
|
||||
});
|
||||
}}
|
||||
data-testid="repeat-notifications-time-input"
|
||||
/>
|
||||
<Select
|
||||
value={notificationSettings.reNotification.unit || null}
|
||||
@@ -55,7 +54,6 @@ function NotificationSettings(): JSX.Element {
|
||||
},
|
||||
});
|
||||
}}
|
||||
data-testid="repeat-notifications-unit-select"
|
||||
/>
|
||||
<Typography.Text>while</Typography.Text>
|
||||
<Select
|
||||
@@ -75,7 +73,6 @@ function NotificationSettings(): JSX.Element {
|
||||
},
|
||||
});
|
||||
}}
|
||||
data-testid="repeat-notifications-conditions-select"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -101,7 +98,6 @@ function NotificationSettings(): JSX.Element {
|
||||
});
|
||||
}}
|
||||
defaultShowInput={notificationSettings.reNotification.enabled}
|
||||
data-testid="repeat-notifications-container"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,30 +171,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.lightMode {
|
||||
.empty-logs-search {
|
||||
&__resources-card {
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&__resources-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__resources-description,
|
||||
&__description-list,
|
||||
&__subtitle {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
&__clear-filters-btn {
|
||||
border: 1px dashed var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableData';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import ExplorerOptionWrapper from '../ExplorerOptionWrapper';
|
||||
import { getExplorerToolBarVisibility } from '../utils';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('hooks/dashboard/useUpdateDashboard');
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
getExplorerToolBarVisibility: jest.fn(),
|
||||
generateRGBAFromHex: jest.fn(() => 'rgba(0, 0, 0, 0.08)'),
|
||||
getRandomColor: jest.fn(() => '#000000'),
|
||||
saveNewViewHandler: jest.fn(),
|
||||
setExplorerToolBarVisibility: jest.fn(),
|
||||
DATASOURCE_VS_ROUTES: {},
|
||||
}));
|
||||
|
||||
const mockGetExplorerToolBarVisibility = jest.mocked(
|
||||
getExplorerToolBarVisibility,
|
||||
);
|
||||
|
||||
const mockUseUpdateDashboard = jest.mocked(useUpdateDashboard);
|
||||
|
||||
// Mock data
|
||||
const TEST_QUERY_ID = 'test-query-id';
|
||||
const TEST_DASHBOARD_ID = 'test-dashboard-id';
|
||||
const TEST_DASHBOARD_TITLE = 'Test Dashboard';
|
||||
const TEST_DASHBOARD_DESCRIPTION = 'Test Description';
|
||||
const TEST_TIMESTAMP = '2023-01-01T00:00:00Z';
|
||||
const TEST_DASHBOARD_TITLE_2 = 'Test Dashboard for Export';
|
||||
const NEW_DASHBOARD_ID = 'new-dashboard-id';
|
||||
const DASHBOARDS_API_ENDPOINT = '*/api/v1/dashboards';
|
||||
|
||||
// Use the existing mock query from the codebase
|
||||
const mockQuery: Query = {
|
||||
...MOCK_QUERY,
|
||||
id: TEST_QUERY_ID, // Override with our test ID
|
||||
} as Query;
|
||||
|
||||
const createMockDashboard = (id: string = TEST_DASHBOARD_ID): Dashboard => ({
|
||||
id,
|
||||
data: {
|
||||
title: TEST_DASHBOARD_TITLE,
|
||||
description: TEST_DASHBOARD_DESCRIPTION,
|
||||
tags: [],
|
||||
layout: [],
|
||||
variables: {},
|
||||
},
|
||||
createdAt: TEST_TIMESTAMP,
|
||||
updatedAt: TEST_TIMESTAMP,
|
||||
createdBy: 'test-user',
|
||||
updatedBy: 'test-user',
|
||||
});
|
||||
|
||||
const ADD_TO_DASHBOARD_BUTTON_NAME = /add to dashboard/i;
|
||||
|
||||
// Helper function to render component with props
|
||||
const renderExplorerOptionWrapper = (
|
||||
overrides = {},
|
||||
): ReturnType<typeof render> => {
|
||||
const props = {
|
||||
disabled: false,
|
||||
query: mockQuery,
|
||||
isLoading: false,
|
||||
onExport: jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
>,
|
||||
sourcepage: DataSource.LOGS,
|
||||
isOneChartPerQuery: false,
|
||||
splitedQueries: [],
|
||||
signalSource: 'test-signal',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<ExplorerOptionWrapper
|
||||
disabled={props.disabled}
|
||||
query={props.query}
|
||||
isLoading={props.isLoading}
|
||||
onExport={props.onExport}
|
||||
sourcepage={props.sourcepage}
|
||||
isOneChartPerQuery={props.isOneChartPerQuery}
|
||||
splitedQueries={props.splitedQueries}
|
||||
signalSource={props.signalSource}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('ExplorerOptionWrapper', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetExplorerToolBarVisibility.mockReturnValue(true);
|
||||
|
||||
// Mock useUpdateDashboard to return a mutation object
|
||||
mockUseUpdateDashboard.mockReturnValue(({
|
||||
mutate: jest.fn(),
|
||||
mutateAsync: jest.fn(),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
data: undefined,
|
||||
error: null,
|
||||
reset: jest.fn(),
|
||||
} as unknown) as ReturnType<typeof useUpdateDashboard>);
|
||||
});
|
||||
|
||||
describe('onExport functionality', () => {
|
||||
it('should call onExport when New Dashboard button is clicked in export modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
>;
|
||||
|
||||
// Mock the dashboard creation API
|
||||
const mockNewDashboard = createMockDashboard(NEW_DASHBOARD_ID);
|
||||
server.use(
|
||||
rest.post(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockNewDashboard })),
|
||||
),
|
||||
);
|
||||
|
||||
renderExplorerOptionWrapper({
|
||||
onExport: testOnExport,
|
||||
});
|
||||
|
||||
// Find and click the "Add to Dashboard" button
|
||||
const addToDashboardButton = screen.getByRole('button', {
|
||||
name: ADD_TO_DASHBOARD_BUTTON_NAME,
|
||||
});
|
||||
await user.click(addToDashboardButton);
|
||||
|
||||
// Wait for the export modal to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the "New Dashboard" button
|
||||
const newDashboardButton = screen.getByRole('button', {
|
||||
name: /new dashboard/i,
|
||||
});
|
||||
await user.click(newDashboardButton);
|
||||
|
||||
// Wait for the API call to complete and onExport to be called
|
||||
await waitFor(() => {
|
||||
expect(testOnExport).toHaveBeenCalledWith(mockNewDashboard, true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onExport when selecting existing dashboard and clicking Export button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
>;
|
||||
|
||||
// Mock existing dashboards with unique titles
|
||||
const mockDashboard1 = createMockDashboard('dashboard-1');
|
||||
mockDashboard1.data.title = 'Dashboard 1';
|
||||
const mockDashboard2 = createMockDashboard('dashboard-2');
|
||||
mockDashboard2.data.title = 'Dashboard 2';
|
||||
const mockDashboards = [mockDashboard1, mockDashboard2];
|
||||
|
||||
server.use(
|
||||
rest.get(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockDashboards })),
|
||||
),
|
||||
);
|
||||
|
||||
renderExplorerOptionWrapper({
|
||||
onExport: testOnExport,
|
||||
});
|
||||
|
||||
// Find and click the "Add to Dashboard" button
|
||||
const addToDashboardButton = screen.getByRole('button', {
|
||||
name: ADD_TO_DASHBOARD_BUTTON_NAME,
|
||||
});
|
||||
await user.click(addToDashboardButton);
|
||||
|
||||
// Wait for the export modal to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
expect(dashboardSelect).toBeInTheDocument();
|
||||
await user.click(dashboardSelect);
|
||||
|
||||
// Wait for the dropdown options to appear and select the first dashboard
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockDashboard1.data.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the first dashboard option
|
||||
const dashboardOption = screen.getByText(mockDashboard1.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for onExport to be called with the selected dashboard
|
||||
await waitFor(() => {
|
||||
expect(testOnExport).toHaveBeenCalledWith(mockDashboard1, false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should test actual handleExport function with generateExportToDashboardLink and verify useUpdateDashboard is NOT called', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Mock the safeNavigate function
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
// Get the mock mutate function to track calls
|
||||
const mockMutate = mockUseUpdateDashboard().mutate as jest.MockedFunction<
|
||||
(...args: unknown[]) => void
|
||||
>;
|
||||
|
||||
const panelTypeParam = PANEL_TYPES.TIME_SERIES;
|
||||
const widgetId = v4();
|
||||
const query = mockQuery;
|
||||
|
||||
// Create a real handleExport function similar to LogsExplorerViews
|
||||
// This should NOT call useUpdateDashboard (as per PR #8029)
|
||||
const handleExport = (dashboard: Dashboard | null): void => {
|
||||
if (!dashboard) return;
|
||||
|
||||
// Call the actual generateExportToDashboardLink function (not mocked)
|
||||
const dashboardEditView = generateExportToDashboardLink({
|
||||
query,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
// Simulate navigation
|
||||
mockSafeNavigate(dashboardEditView);
|
||||
};
|
||||
|
||||
// Mock existing dashboards
|
||||
const mockDashboard = createMockDashboard('test-dashboard-id');
|
||||
mockDashboard.data.title = TEST_DASHBOARD_TITLE_2;
|
||||
|
||||
server.use(
|
||||
rest.get(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [mockDashboard] })),
|
||||
),
|
||||
);
|
||||
|
||||
renderExplorerOptionWrapper({
|
||||
onExport: handleExport,
|
||||
});
|
||||
|
||||
// Find and click the "Add to Dashboard" button
|
||||
const addToDashboardButton = screen.getByRole('button', {
|
||||
name: ADD_TO_DASHBOARD_BUTTON_NAME,
|
||||
});
|
||||
await user.click(addToDashboardButton);
|
||||
|
||||
// Wait for the export modal to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
expect(dashboardSelect).toBeInTheDocument();
|
||||
await user.click(dashboardSelect);
|
||||
|
||||
// Wait for the dropdown options to appear and select the dashboard
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockDashboard.data.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the dashboard option
|
||||
const dashboardOption = screen.getByText(mockDashboard.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for the handleExport function to be called and navigation to occur
|
||||
await waitFor(() => {
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/dashboard/test-dashboard-id/new?graphType=${panelTypeParam}&widgetId=${widgetId}&compositeQuery=${encodeURIComponent(
|
||||
JSON.stringify(query),
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Assert that useUpdateDashboard was NOT called (as per PR #8029)
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show export buttons when component is disabled', () => {
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
>;
|
||||
|
||||
renderExplorerOptionWrapper({ disabled: true, onExport: testOnExport });
|
||||
|
||||
// The "Add to Dashboard" button should be disabled
|
||||
const addToDashboardButton = screen.getByRole('button', {
|
||||
name: ADD_TO_DASHBOARD_BUTTON_NAME,
|
||||
});
|
||||
expect(addToDashboardButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -137,9 +137,8 @@ function GeneralSettings({
|
||||
if (logsCurrentTTLValues) {
|
||||
setLogsTotalRetentionPeriod(logsCurrentTTLValues.default_ttl_days * 24);
|
||||
setLogsS3RetentionPeriod(
|
||||
logsCurrentTTLValues.cold_storage_ttl_days &&
|
||||
logsCurrentTTLValues.cold_storage_ttl_days > 0
|
||||
? logsCurrentTTLValues.cold_storage_ttl_days * 24
|
||||
logsCurrentTTLValues.logs_move_ttl_duration_hrs
|
||||
? logsCurrentTTLValues.logs_move_ttl_duration_hrs
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -199,12 +198,7 @@ function GeneralSettings({
|
||||
);
|
||||
|
||||
const s3Enabled = useMemo(
|
||||
() =>
|
||||
!!find(
|
||||
availableDisks,
|
||||
(disks: IDiskType) =>
|
||||
disks?.type === 's3' || disks?.type === 'ObjectStorage',
|
||||
),
|
||||
() => !!find(availableDisks, (disks: IDiskType) => disks?.type === 's3'),
|
||||
[availableDisks],
|
||||
);
|
||||
|
||||
@@ -295,9 +289,8 @@ function GeneralSettings({
|
||||
isTracesSaveDisabled = true;
|
||||
|
||||
if (
|
||||
logsCurrentTTLValues.default_ttl_days * 24 === logsTotalRetentionPeriod &&
|
||||
logsCurrentTTLValues.cold_storage_ttl_days &&
|
||||
logsCurrentTTLValues.cold_storage_ttl_days * 24 === logsS3RetentionPeriod
|
||||
logsCurrentTTLValues.logs_ttl_duration_hrs === logsTotalRetentionPeriod &&
|
||||
logsCurrentTTLValues.logs_move_ttl_duration_hrs === logsS3RetentionPeriod
|
||||
)
|
||||
isLogsSaveDisabled = true;
|
||||
|
||||
@@ -308,8 +301,8 @@ function GeneralSettings({
|
||||
errorText,
|
||||
];
|
||||
}, [
|
||||
logsCurrentTTLValues.cold_storage_ttl_days,
|
||||
logsCurrentTTLValues.default_ttl_days,
|
||||
logsCurrentTTLValues.logs_move_ttl_duration_hrs,
|
||||
logsCurrentTTLValues.logs_ttl_duration_hrs,
|
||||
logsS3RetentionPeriod,
|
||||
logsTotalRetentionPeriod,
|
||||
metricsCurrentTTLValues.metrics_move_ttl_duration_hrs,
|
||||
@@ -355,17 +348,11 @@ function GeneralSettings({
|
||||
|
||||
try {
|
||||
if (type === 'logs') {
|
||||
// Only send S3 values if user has specified a duration
|
||||
const s3RetentionDays =
|
||||
apiCallS3Retention && apiCallS3Retention > 0
|
||||
? apiCallS3Retention / 24
|
||||
: 0;
|
||||
|
||||
await setRetentionApiV2({
|
||||
type,
|
||||
defaultTTLDays: apiCallTotalRetention ? apiCallTotalRetention / 24 : -1, // convert Hours to days
|
||||
coldStorageVolume: s3RetentionDays > 0 ? 's3' : '',
|
||||
coldStorageDurationDays: s3RetentionDays,
|
||||
coldStorageVolume: '',
|
||||
coldStorageDuration: 0,
|
||||
ttlConditions: [],
|
||||
});
|
||||
} else {
|
||||
@@ -419,9 +406,8 @@ function GeneralSettings({
|
||||
// Updates the currentTTL Values in order to avoid pushing the same values.
|
||||
setLogsCurrentTTLValues((prev) => ({
|
||||
...prev,
|
||||
cold_storage_ttl_days: logsS3RetentionPeriod
|
||||
? logsS3RetentionPeriod / 24
|
||||
: -1,
|
||||
logs_ttl_duration_hrs: logsTotalRetentionPeriod || -1,
|
||||
logs_move_ttl_duration_hrs: logsS3RetentionPeriod || -1,
|
||||
default_ttl_days: logsTotalRetentionPeriod
|
||||
? logsTotalRetentionPeriod / 24 // convert Hours to days
|
||||
: -1,
|
||||
@@ -538,7 +524,6 @@ function GeneralSettings({
|
||||
value: logsS3RetentionPeriod,
|
||||
setValue: setLogsS3RetentionPeriod,
|
||||
hide: !s3Enabled,
|
||||
isS3Field: true,
|
||||
},
|
||||
],
|
||||
save: {
|
||||
@@ -592,7 +577,6 @@ function GeneralSettings({
|
||||
retentionValue={retentionField.value}
|
||||
setRetentionValue={retentionField.setValue}
|
||||
hide={!!retentionField.hide}
|
||||
isS3Field={'isS3Field' in retentionField && retentionField.isS3Field}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -33,31 +32,11 @@ function Retention({
|
||||
setRetentionValue,
|
||||
text,
|
||||
hide,
|
||||
isS3Field = false,
|
||||
}: RetentionProps): JSX.Element | null {
|
||||
// Filter available units based on type and field
|
||||
const availableUnits = useMemo(
|
||||
() =>
|
||||
TimeUnits.filter((option) => {
|
||||
if (type === 'logs') {
|
||||
// For S3 cold storage fields: only allow Days
|
||||
if (isS3Field) {
|
||||
return option.value === TimeUnitsValues.day;
|
||||
}
|
||||
// For total retention: allow Days and Months (not Hours)
|
||||
return option.value !== TimeUnitsValues.hr;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[type, isS3Field],
|
||||
);
|
||||
|
||||
// Convert the hours value using only the available units
|
||||
const {
|
||||
value: initialValue,
|
||||
timeUnitValue: initialTimeUnitValue,
|
||||
} = convertHoursValueToRelevantUnit(Number(retentionValue), availableUnits);
|
||||
|
||||
} = convertHoursValueToRelevantUnit(Number(retentionValue));
|
||||
const [selectedTimeUnit, setSelectTimeUnit] = useState(initialTimeUnitValue);
|
||||
const [selectedValue, setSelectedValue] = useState<number | null>(
|
||||
initialValue,
|
||||
@@ -74,27 +53,29 @@ function Retention({
|
||||
if (!interacted.current) setSelectTimeUnit(initialTimeUnitValue);
|
||||
}, [initialTimeUnitValue]);
|
||||
|
||||
const menuItems = availableUnits.map((option) => (
|
||||
const menuItems = TimeUnits.filter((option) =>
|
||||
type === 'logs' ? option.value !== TimeUnitsValues.hr : true,
|
||||
).map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.key}
|
||||
</Option>
|
||||
));
|
||||
|
||||
const currentSelectedOption = (option: SettingPeriod): void => {
|
||||
const selectedValue = find(availableUnits, (e) => e.value === option)?.value;
|
||||
const selectedValue = find(TimeUnits, (e) => e.value === option)?.value;
|
||||
if (selectedValue) setSelectTimeUnit(selectedValue);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const inverseMultiplier = find(
|
||||
availableUnits,
|
||||
TimeUnits,
|
||||
(timeUnit) => timeUnit.value === selectedTimeUnit,
|
||||
)?.multiplier;
|
||||
if (!selectedValue) setRetentionValue(null);
|
||||
if (selectedValue && inverseMultiplier) {
|
||||
setRetentionValue(selectedValue * (1 / inverseMultiplier));
|
||||
}
|
||||
}, [selectedTimeUnit, selectedValue, setRetentionValue, availableUnits]);
|
||||
}, [selectedTimeUnit, selectedValue, setRetentionValue]);
|
||||
|
||||
const onChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement>,
|
||||
@@ -153,10 +134,6 @@ interface RetentionProps {
|
||||
text: string;
|
||||
setRetentionValue: Dispatch<SetStateAction<number | null>>;
|
||||
hide: boolean;
|
||||
isS3Field?: boolean;
|
||||
}
|
||||
|
||||
Retention.defaultProps = {
|
||||
isS3Field: false,
|
||||
};
|
||||
export default Retention;
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { IDiskType } from 'types/api/disks/getDisks';
|
||||
import {
|
||||
PayloadPropsLogs,
|
||||
PayloadPropsMetrics,
|
||||
PayloadPropsTraces,
|
||||
} from 'types/api/settings/getRetention';
|
||||
|
||||
import GeneralSettings from '../GeneralSettings';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('api/settings/setRetentionV2');
|
||||
|
||||
const mockNotifications = {
|
||||
error: jest.fn(),
|
||||
success: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): { notifications: typeof mockNotifications } => ({
|
||||
notifications: mockNotifications,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useComponentPermission', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => [true]),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: (): { isCloudUser: boolean } => ({
|
||||
isCloudUser: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/GeneralSettingsCloud', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockMetricsRetention: PayloadPropsMetrics = {
|
||||
metrics_ttl_duration_hrs: 168,
|
||||
metrics_move_ttl_duration_hrs: -1,
|
||||
status: '',
|
||||
};
|
||||
|
||||
const mockTracesRetention: PayloadPropsTraces = {
|
||||
traces_ttl_duration_hrs: 168,
|
||||
traces_move_ttl_duration_hrs: -1,
|
||||
status: '',
|
||||
};
|
||||
|
||||
const mockLogsRetentionWithS3: PayloadPropsLogs = {
|
||||
version: 'v2',
|
||||
default_ttl_days: 30,
|
||||
cold_storage_ttl_days: 24,
|
||||
status: '',
|
||||
};
|
||||
|
||||
const mockLogsRetentionWithoutS3: PayloadPropsLogs = {
|
||||
version: 'v2',
|
||||
default_ttl_days: 30,
|
||||
cold_storage_ttl_days: -1,
|
||||
status: '',
|
||||
};
|
||||
|
||||
const mockDisksWithS3: IDiskType[] = [
|
||||
{
|
||||
name: 'default',
|
||||
type: 's3',
|
||||
},
|
||||
];
|
||||
|
||||
const mockDisksWithObjectStorage: IDiskType[] = [
|
||||
{
|
||||
name: 'default',
|
||||
type: 'ObjectStorage',
|
||||
},
|
||||
];
|
||||
|
||||
const mockDisksWithoutS3: IDiskType[] = [
|
||||
{
|
||||
name: 'default',
|
||||
type: 'local',
|
||||
},
|
||||
];
|
||||
|
||||
describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(setRetentionApiV2 as jest.Mock).mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { message: 'success' },
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 1: S3 Enabled - Only Days in Dropdown', () => {
|
||||
it('should show only Days option for S3 retention and send correct API payload', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithS3}
|
||||
getAvailableDiskPayload={mockDisksWithS3}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
expect(logsCard).toBeInTheDocument();
|
||||
|
||||
// Find all inputs in the Logs card - there should be 2 (total retention + S3)
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
expect(inputs).toHaveLength(2);
|
||||
|
||||
// The second input is the S3 retention field
|
||||
const s3Input = inputs?.[1] as HTMLInputElement;
|
||||
|
||||
// Find the S3 dropdown (next sibling of the S3 input)
|
||||
const s3Dropdown = s3Input?.nextElementSibling?.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement;
|
||||
expect(s3Dropdown).toBeInTheDocument();
|
||||
|
||||
// Click the S3 dropdown to open it
|
||||
fireEvent.mouseDown(s3Dropdown);
|
||||
|
||||
// Wait for dropdown options to appear and verify only "Days" is available
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
const dropdownOptions = document.querySelectorAll('.ant-select-item');
|
||||
expect(dropdownOptions).toHaveLength(1);
|
||||
expect(dropdownOptions[0]).toHaveTextContent('Days');
|
||||
});
|
||||
|
||||
// Close dropdown
|
||||
fireEvent.click(document.body);
|
||||
|
||||
// Change S3 retention value to 5 days
|
||||
await user.clear(s3Input);
|
||||
await user.type(s3Input, '5');
|
||||
|
||||
// Find the save button in the Logs card
|
||||
const buttons = logsCard?.querySelectorAll('button[type="button"]');
|
||||
// The primary button should be the save button
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes('ant-btn-primary'),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Wait for button to be enabled (it should enable after value changes)
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Wait for modal to appear
|
||||
const modal = await screen.findByRole('dialog');
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// Click OK button
|
||||
const okButton = await screen.findByRole('button', { name: /ok/i });
|
||||
fireEvent.click(okButton);
|
||||
|
||||
// Verify API was called with correct payload
|
||||
await waitFor(() => {
|
||||
expect(setRetentionApiV2).toHaveBeenCalledWith({
|
||||
type: 'logs',
|
||||
defaultTTLDays: 30,
|
||||
coldStorageVolume: 's3',
|
||||
coldStorageDurationDays: 5,
|
||||
ttlConditions: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should recognize ObjectStorage disk type as S3 enabled', async () => {
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithS3}
|
||||
getAvailableDiskPayload={mockDisksWithObjectStorage}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify S3 field is visible
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
expect(inputs).toHaveLength(2); // Total + S3
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 2: S3 Disabled - Field Hidden', () => {
|
||||
it('should hide S3 retention field and send empty S3 values to API', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithoutS3}
|
||||
getAvailableDiskPayload={mockDisksWithoutS3}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
expect(logsCard).toBeInTheDocument();
|
||||
|
||||
// Only 1 input should be visible (total retention, no S3)
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
expect(inputs).toHaveLength(1);
|
||||
|
||||
// Change total retention value
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
|
||||
// First, change the dropdown to Days (it defaults to Months)
|
||||
const totalDropdown = totalInput?.nextElementSibling?.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement;
|
||||
await user.click(totalDropdown);
|
||||
|
||||
// Wait for dropdown options to appear
|
||||
await waitFor(() => {
|
||||
const options = document.querySelectorAll('.ant-select-item');
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Find and click the Days option
|
||||
const options = document.querySelectorAll('.ant-select-item');
|
||||
const daysOption = Array.from(options).find((opt) =>
|
||||
opt.textContent?.includes('Days'),
|
||||
);
|
||||
expect(daysOption).toBeInTheDocument();
|
||||
await user.click(daysOption as HTMLElement);
|
||||
|
||||
// Now change the value
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '60');
|
||||
|
||||
// Find the save button
|
||||
const buttons = logsCard?.querySelectorAll('button[type="button"]');
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes('ant-btn-primary'),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Wait for button to be enabled (ensures all state updates have settled)
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click save button
|
||||
await user.click(saveButton);
|
||||
|
||||
// Wait for modal to appear
|
||||
const okButton = await screen.findByRole('button', { name: /ok/i });
|
||||
expect(okButton).toBeInTheDocument();
|
||||
|
||||
// Click OK button
|
||||
await user.click(okButton);
|
||||
|
||||
// Verify API was called with empty S3 values (60 days)
|
||||
await waitFor(() => {
|
||||
expect(setRetentionApiV2).toHaveBeenCalledWith({
|
||||
type: 'logs',
|
||||
defaultTTLDays: 60,
|
||||
coldStorageVolume: '',
|
||||
coldStorageDurationDays: 0,
|
||||
ttlConditions: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 3: Save & Reload - Correct Display', () => {
|
||||
it('should display retention values correctly after converting from hours', () => {
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithS3}
|
||||
getAvailableDiskPayload={mockDisksWithS3}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
|
||||
// Total retention: 720 hours = 30 days = 1 month (displays as 1 Month)
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
expect(totalInput.value).toBe('1');
|
||||
|
||||
// S3 retention: 24 day
|
||||
const s3Input = inputs?.[1] as HTMLInputElement;
|
||||
expect(s3Input.value).toBe('24');
|
||||
|
||||
// Verify dropdowns: total shows Months, S3 shows Days
|
||||
const dropdowns = logsCard?.querySelectorAll('.ant-select-selection-item');
|
||||
expect(dropdowns?.[0]).toHaveTextContent('Months');
|
||||
expect(dropdowns?.[1]).toHaveTextContent('Days');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,22 +34,12 @@ interface ITimeUnitConversion {
|
||||
value: number;
|
||||
timeUnitValue: SettingPeriod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts hours value to the most relevant unit from the available units.
|
||||
* @param value - The value in hours
|
||||
* @param availableUnits - Optional array of available time units to consider. If not provided, all units are considered.
|
||||
* @returns The converted value and the selected time unit
|
||||
*/
|
||||
export const convertHoursValueToRelevantUnit = (
|
||||
value: number,
|
||||
availableUnits?: ITimeUnit[],
|
||||
): ITimeUnitConversion => {
|
||||
const unitsToConsider = availableUnits?.length ? availableUnits : TimeUnits;
|
||||
|
||||
if (value) {
|
||||
for (let idx = unitsToConsider.length - 1; idx >= 0; idx -= 1) {
|
||||
const timeUnit = unitsToConsider[idx];
|
||||
if (value)
|
||||
for (let idx = TimeUnits.length - 1; idx >= 0; idx -= 1) {
|
||||
const timeUnit = TimeUnits[idx];
|
||||
const convertedValue = timeUnit.multiplier * value;
|
||||
|
||||
if (
|
||||
@@ -59,10 +49,7 @@ export const convertHoursValueToRelevantUnit = (
|
||||
return { value: convertedValue, timeUnitValue: timeUnit.value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the first available unit
|
||||
return { value, timeUnitValue: unitsToConsider[0].value };
|
||||
return { value, timeUnitValue: TimeUnits[0].value };
|
||||
};
|
||||
|
||||
export const convertHoursValueToRelevantUnitString = (
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { MutableRefObject } from 'react';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('container/PanelWrapper/constants', () => ({
|
||||
PanelTypeVsPanelWrapper: {
|
||||
[PANEL_TYPES.TIME_SERIES]: ({
|
||||
onDragSelect,
|
||||
}: {
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
}): JSX.Element => {
|
||||
const handleCanvasMouseDown = (): void => {
|
||||
// Simulate drag start
|
||||
const handleMouseMove = (): void => {
|
||||
// Simulate drag progress
|
||||
};
|
||||
|
||||
const handleMouseUp = (): void => {
|
||||
// Simulate drag end and call onDragSelect
|
||||
onDragSelect(1634325650, 1634325750);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="mock-time-series-panel">
|
||||
<canvas
|
||||
data-testid="uplot-canvas"
|
||||
width={400}
|
||||
height={300}
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="drag-select-trigger"
|
||||
onClick={(): void => onDragSelect(1634325650, 1634325750)}
|
||||
>
|
||||
Trigger Drag Select
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockWidget: Widgets = {
|
||||
id: 'test-widget-id',
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.METRICS,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'sum',
|
||||
aggregateAttribute: {
|
||||
key: 'test',
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
stepInterval: 60,
|
||||
legend: '',
|
||||
spaceAggregation: 'sum',
|
||||
timeAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'test-query-id',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: 'Test Widget',
|
||||
description: '',
|
||||
opacity: '',
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
nullZeroValues: '',
|
||||
yAxisUnit: '',
|
||||
fillSpans: false,
|
||||
softMin: null,
|
||||
softMax: null,
|
||||
selectedLogFields: [],
|
||||
selectedTracesFields: [],
|
||||
};
|
||||
|
||||
// Mock response data
|
||||
const mockQueryResponse: any = {
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: { __name__: 'test_metric' },
|
||||
values: [[1634325600, '42']],
|
||||
queryName: 'A',
|
||||
},
|
||||
],
|
||||
resultType: '',
|
||||
newResult: {
|
||||
data: {
|
||||
resultType: '',
|
||||
result: [
|
||||
{
|
||||
queryName: 'A',
|
||||
series: null,
|
||||
list: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
statusCode: 200,
|
||||
message: 'success',
|
||||
error: null,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
refetch: jest.fn(),
|
||||
};
|
||||
|
||||
describe('PanelWrapper with DragSelect', () => {
|
||||
const tableProcessedDataRef = { current: [] } as MutableRefObject<RowData[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('simulates drag select on uPlot canvas', async () => {
|
||||
const mockOnDragSelect = jest.fn();
|
||||
|
||||
render(
|
||||
<PanelWrapper
|
||||
widget={mockWidget}
|
||||
queryResponse={mockQueryResponse}
|
||||
onDragSelect={mockOnDragSelect}
|
||||
selectedGraph={PANEL_TYPES.TIME_SERIES}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the panel renders
|
||||
expect(screen.getByTestId('mock-time-series-panel')).toBeInTheDocument();
|
||||
|
||||
// Find the canvas element
|
||||
const canvas = screen.getByTestId('uplot-canvas');
|
||||
expect(canvas).toBeInTheDocument();
|
||||
|
||||
// Simulate drag events on the canvas
|
||||
// Start drag by dispatching mousedown
|
||||
canvas.dispatchEvent(
|
||||
new MouseEvent('mousedown', {
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Simulate mouse move during drag
|
||||
canvas.dispatchEvent(
|
||||
new MouseEvent('mousemove', {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// End drag by dispatching mouseup
|
||||
canvas.dispatchEvent(
|
||||
new MouseEvent('mouseup', {
|
||||
clientX: 80,
|
||||
clientY: 80,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for the onDragSelect to be called
|
||||
await waitFor(() => {
|
||||
expect(mockOnDragSelect).toHaveBeenCalledWith(1634325650, 1634325750);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,9 @@ import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -65,11 +67,13 @@ function FullView({
|
||||
enableDrillDown = false,
|
||||
}: FullViewProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { selectedTime: globalSelectedTime, minTime, maxTime } = useSelector<
|
||||
const { selectedTime: globalSelectedTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
|
||||
const fullViewRef = useRef<HTMLDivElement>(null);
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
@@ -150,16 +154,11 @@ function FullView({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timeRange =
|
||||
selectedTime.enum !== 'GLOBAL_TIME'
|
||||
? { start: undefined, end: undefined }
|
||||
: { start: Math.floor(minTime / 1e9), end: Math.floor(maxTime / 1e9) };
|
||||
setRequestData((prev) => ({
|
||||
...prev,
|
||||
selectedTime: selectedTime.enum,
|
||||
...timeRange,
|
||||
}));
|
||||
}, [selectedTime, minTime, maxTime]);
|
||||
}, [selectedTime]);
|
||||
|
||||
// Update requestData when panel type changes
|
||||
useEffect(() => {
|
||||
@@ -182,34 +181,38 @@ function FullView({
|
||||
});
|
||||
}, [selectedPanelType]);
|
||||
|
||||
const response = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
|
||||
queryKey: [
|
||||
widget?.query,
|
||||
selectedPanelType,
|
||||
requestData,
|
||||
version,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
enabled: !isDependedDataLoaded,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
const response = useGetQueryRange(
|
||||
requestData,
|
||||
// selectedDashboard?.data?.version || version || DEFAULT_ENTITY_VERSION,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey: [widget?.query, selectedPanelType, requestData, version],
|
||||
enabled: !isDependedDataLoaded,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const onDragSelect = useCallback((start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
const { maxTime, minTime } = GetMinMax('custom', [
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
]);
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
|
||||
setRequestData((prev) => ({
|
||||
...prev,
|
||||
start: Math.floor(minTime / 1e9),
|
||||
end: Math.floor(maxTime / 1e9),
|
||||
}));
|
||||
}, []);
|
||||
const { maxTime, minTime } = GetMinMax('custom', [
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
]);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, minTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, maxTime.toString());
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
safeNavigate(generatedUrl);
|
||||
},
|
||||
[dispatch, location.pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
const [graphsVisibilityStates, setGraphsVisibilityStates] = useState<
|
||||
boolean[]
|
||||
|
||||
@@ -350,51 +350,47 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
||||
key: 'action',
|
||||
width: 10,
|
||||
render: (id: GettableAlert['id'], record): JSX.Element => (
|
||||
<div data-testid="alert-actions">
|
||||
<DropDown
|
||||
onDropDownItemClick={(item): void =>
|
||||
alertActionLogEvent(item.key, record)
|
||||
}
|
||||
element={[
|
||||
<ToggleAlertState
|
||||
key="1"
|
||||
disabled={record.disabled}
|
||||
setData={setData}
|
||||
id={id}
|
||||
/>,
|
||||
<ColumnButton
|
||||
key="2"
|
||||
onClick={(): void => onEditHandler(record, false)}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={(): void => onEditHandler(record, true)}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit in New Tab
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={onCloneHandler(record)}
|
||||
type="link"
|
||||
loading={cloneLoader}
|
||||
>
|
||||
Clone
|
||||
</ColumnButton>,
|
||||
<DeleteAlert
|
||||
key="4"
|
||||
notifications={notificationsApi}
|
||||
setData={setData}
|
||||
id={id}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<DropDown
|
||||
onDropDownItemClick={(item): void => alertActionLogEvent(item.key, record)}
|
||||
element={[
|
||||
<ToggleAlertState
|
||||
key="1"
|
||||
disabled={record.disabled}
|
||||
setData={setData}
|
||||
id={id}
|
||||
/>,
|
||||
<ColumnButton
|
||||
key="2"
|
||||
onClick={(): void => onEditHandler(record, false)}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={(): void => onEditHandler(record, true)}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit in New Tab
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={onCloneHandler(record)}
|
||||
type="link"
|
||||
loading={cloneLoader}
|
||||
>
|
||||
Clone
|
||||
</ColumnButton>,
|
||||
<DeleteAlert
|
||||
key="4"
|
||||
notifications={notificationsApi}
|
||||
setData={setData}
|
||||
id={id}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import dompurify from 'dompurify';
|
||||
import { uniqueId } from 'lodash-es';
|
||||
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
|
||||
import { FORBID_DOM_PURIFY_TAGS } from 'utils/app';
|
||||
|
||||
import BodyTitleRenderer from './BodyTitleRenderer';
|
||||
import { typeToArrayTypeMapper } from './config';
|
||||
@@ -352,7 +352,6 @@ export const getSanitizedLogBody = (
|
||||
return convertInstance.toHtml(
|
||||
dompurify.sanitize(unescapeString(escapedText), {
|
||||
FORBID_TAGS: [...FORBID_DOM_PURIFY_TAGS],
|
||||
FORBID_ATTR: [...FORBID_DOM_PURIFY_ATTR],
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import {
|
||||
DataSource,
|
||||
QueryBuilderContextType,
|
||||
ReduceOperators,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import useInitialQuery from '../useInitialQuery';
|
||||
|
||||
// Mock the queryBuilder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the convertFiltersToExpression utility
|
||||
jest.mock('components/QueryBuilderV2/utils', () => ({
|
||||
convertFiltersToExpression: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock uuid for consistent testing
|
||||
jest.mock('uuid', () => ({
|
||||
v4: jest.fn(() => 'test-uuid'),
|
||||
}));
|
||||
|
||||
// Type the mocked functions
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
const mockedConvertFiltersToExpression = jest.mocked(
|
||||
convertFiltersToExpression,
|
||||
);
|
||||
|
||||
describe('useInitialQuery - Priority-Based Resource Filtering', () => {
|
||||
const mockUpdateAllQueriesOperators = jest.fn();
|
||||
const mockBaseQuery: Query = {
|
||||
id: 'test-query',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: '',
|
||||
aggregateAttribute: {
|
||||
key: '',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: '',
|
||||
spaceAggregation: '',
|
||||
functions: [],
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
groupBy: [],
|
||||
having: [],
|
||||
orderBy: [],
|
||||
limit: null,
|
||||
offset: 0,
|
||||
pageSize: 0,
|
||||
stepInterval: 60,
|
||||
queryName: 'A',
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
reduceTo: 'avg' as ReduceOperators,
|
||||
legend: '',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup useQueryBuilder mock - only mock what we need
|
||||
mockedUseQueryBuilder.mockReturnValue(({
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
// Setup the mock to return base query
|
||||
mockUpdateAllQueriesOperators.mockReturnValue(mockBaseQuery);
|
||||
|
||||
// Setup convertFiltersToExpression mock
|
||||
mockedConvertFiltersToExpression.mockReturnValue({
|
||||
expression: 'test-expression',
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to create test log with resources
|
||||
const createTestLog = (resources: Record<string, string>): ILog => ({
|
||||
date: '2023-10-20',
|
||||
timestamp: 1697788800000,
|
||||
id: 'test-log-id',
|
||||
traceId: 'test-trace-id',
|
||||
spanID: 'test-span-id',
|
||||
span_id: 'test-span-id',
|
||||
traceFlags: 0,
|
||||
severityText: 'INFO',
|
||||
severityNumber: 9,
|
||||
body: 'Test log message',
|
||||
resources_string: resources as Record<string, never>,
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
});
|
||||
|
||||
// Helper function to assert that specific keys are NOT present in filter items
|
||||
const assertKeysNotPresent = (
|
||||
items: TagFilterItem[],
|
||||
excludedKeys: string[],
|
||||
): void => {
|
||||
excludedKeys.forEach((key) => {
|
||||
const found = items.find((item) => item.key?.key === key);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
};
|
||||
|
||||
describe('K8s Environment Context Flow', () => {
|
||||
it('should include service.name and k8s.pod.name when user opens log context from Kubernetes pod', () => {
|
||||
// Log from k8s pod with multiple resource attributes
|
||||
const testLog = createTestLog({
|
||||
'service.name': 'frontend-service',
|
||||
'deployment.environment': 'production',
|
||||
'k8s.pod.name': 'frontend-pod-abc123',
|
||||
'k8s.pod.uid': 'pod-uid-xyz789',
|
||||
'k8s.deployment.name': 'frontend-deployment',
|
||||
'host.name': 'worker-node-1',
|
||||
'container.id': 'container-abc123',
|
||||
'random.attribute': 'should-be-filtered-out',
|
||||
});
|
||||
|
||||
// User opens log context (hook executes)
|
||||
const { result } = renderHook(() => useInitialQuery(testLog));
|
||||
|
||||
// Query includes only service.name + first k8s priority item
|
||||
const generatedQuery = result.current;
|
||||
expect(generatedQuery).toBeDefined();
|
||||
|
||||
// Verify that updateAllQueriesOperators was called with correct params
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledWith(
|
||||
expect.any(Object), // initialQueriesMap.logs
|
||||
'list', // PANEL_TYPES.LIST
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
// Verify convertFiltersToExpression was called
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
key: expect.objectContaining({ key: 'service.name' }),
|
||||
value: 'frontend-service',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'deployment.environment' }),
|
||||
value: 'production',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'k8s.pod.uid' }), // First priority k8s item
|
||||
value: 'pod-uid-xyz789',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify exact count of filter items (should be exactly 3)
|
||||
const calledWith = mockedConvertFiltersToExpression.mock.calls[0][0];
|
||||
expect(calledWith.items).toHaveLength(3);
|
||||
|
||||
// Verify specific unwanted keys are excluded
|
||||
assertKeysNotPresent(calledWith.items, [
|
||||
'k8s.pod.name', // Other k8s attributes should be excluded
|
||||
'k8s.deployment.name',
|
||||
'host.name', // Lower priority attributes should be excluded
|
||||
'container.id',
|
||||
'random.attribute', // Non-matching attributes should be excluded
|
||||
]);
|
||||
|
||||
// Verify exact call counts to catch unintended multiple invocations
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud Environment Flow', () => {
|
||||
it('should include service.name and cloud.resource_id when user opens log context from cloud service without k8s', () => {
|
||||
// Log from cloud service (no k8s attributes)
|
||||
const testLog = createTestLog({
|
||||
'service.name': 'api-gateway',
|
||||
env: 'staging',
|
||||
'cloud.resource_id': 'i-0abcdef1234567890',
|
||||
'cloud.provider': 'aws',
|
||||
'cloud.region': 'us-east-1',
|
||||
'host.name': 'ip-10-0-1-100',
|
||||
'host.id': 'host-xyz123',
|
||||
'unnecessary.tag': 'filtered-out',
|
||||
});
|
||||
|
||||
// User opens log context (hook executes)
|
||||
const { result } = renderHook(() => useInitialQuery(testLog));
|
||||
|
||||
// Query includes service + env + first cloud priority item (skips host due to priority)
|
||||
const generatedQuery = result.current;
|
||||
expect(generatedQuery).toBeDefined();
|
||||
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'service.name' }),
|
||||
value: 'api-gateway',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'env' }),
|
||||
value: 'staging',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'cloud.resource_id' }), // First priority cloud item
|
||||
value: 'i-0abcdef1234567890',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify exact count of filter items (should be exactly 3)
|
||||
const calledWith = mockedConvertFiltersToExpression.mock.calls[0][0];
|
||||
expect(calledWith.items).toHaveLength(3);
|
||||
|
||||
// Verify host attributes are NOT included due to lower priority
|
||||
const hostItems = calledWith.items.filter((item: TagFilterItem) =>
|
||||
item.key?.key?.startsWith('host.'),
|
||||
);
|
||||
expect(hostItems).toHaveLength(0);
|
||||
|
||||
// Verify specific unwanted keys are excluded
|
||||
assertKeysNotPresent(calledWith.items, [
|
||||
'cloud.provider',
|
||||
'cloud.region',
|
||||
'host.name',
|
||||
'host.id',
|
||||
'unnecessary.tag',
|
||||
]);
|
||||
|
||||
// Verify exact call counts to catch unintended multiple invocations
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback Environment Flow', () => {
|
||||
it('should include service.name and deployment.name when user opens log context from basic deployment without priority attributes', () => {
|
||||
// Log from basic deployment (no k8s, cloud, host, or container)
|
||||
const testLog = createTestLog({
|
||||
'service.name': 'legacy-app',
|
||||
'deployment.environment': 'production',
|
||||
'deployment.name': 'legacy-deployment',
|
||||
'file.path': '/var/log/app.log',
|
||||
'random.key': 'ignored',
|
||||
'another.attribute': 'also-ignored',
|
||||
});
|
||||
|
||||
// User opens log context (hook executes)
|
||||
const { result } = renderHook(() => useInitialQuery(testLog));
|
||||
|
||||
// Query includes service + environment + fallback regex matches
|
||||
const generatedQuery = result.current;
|
||||
expect(generatedQuery).toBeDefined();
|
||||
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'service.name' }),
|
||||
value: 'legacy-app',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'deployment.environment' }),
|
||||
value: 'production',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'deployment.name' }), // Fallback regex match
|
||||
value: 'legacy-deployment',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'file.path' }), // Fallback regex match
|
||||
value: '/var/log/app.log',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify exact count of filter items (should be exactly 4)
|
||||
const calledWith = mockedConvertFiltersToExpression.mock.calls[0][0];
|
||||
expect(calledWith.items).toHaveLength(4);
|
||||
|
||||
// Verify specific unwanted keys are excluded
|
||||
assertKeysNotPresent(calledWith.items, ['random.key', 'another.attribute']);
|
||||
|
||||
// Verify exact call counts to catch unintended multiple invocations
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service-Only Minimal Flow', () => {
|
||||
it('should include at least service.name when user opens log context with minimal attributes', () => {
|
||||
// Log with only service and unmatched attributes
|
||||
const testLog = createTestLog({
|
||||
'service.name': 'minimal-service',
|
||||
'custom.tag': 'business-value',
|
||||
'user.id': 'user-123',
|
||||
'request.id': 'req-abc',
|
||||
});
|
||||
|
||||
// User opens log context (hook executes)
|
||||
const { result } = renderHook(() => useInitialQuery(testLog));
|
||||
|
||||
// Query includes at least service.name (essential for filtering)
|
||||
const generatedQuery = result.current;
|
||||
expect(generatedQuery).toBeDefined();
|
||||
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'service.name' }),
|
||||
value: 'minimal-service',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify exact count of filter items (should be exactly 1)
|
||||
const calledWith = mockedConvertFiltersToExpression.mock.calls[0][0];
|
||||
expect(calledWith.items).toHaveLength(1);
|
||||
|
||||
// Verify that service.name is included
|
||||
const serviceItems = calledWith.items.filter(
|
||||
(item: TagFilterItem) => item.key?.key === 'service.name',
|
||||
);
|
||||
expect(serviceItems.length).toBe(1);
|
||||
|
||||
// Verify no priority items (k8s, cloud, host, container) are included
|
||||
const priorityItems = calledWith.items.filter(
|
||||
(item: TagFilterItem) =>
|
||||
item.key?.key &&
|
||||
(item.key.key.startsWith('k8s.') ||
|
||||
item.key.key.startsWith('cloud.') ||
|
||||
item.key.key.startsWith('host.') ||
|
||||
item.key.key.startsWith('container.')),
|
||||
);
|
||||
expect(priorityItems).toHaveLength(0);
|
||||
|
||||
// Verify specific unwanted keys are excluded
|
||||
assertKeysNotPresent(calledWith.items, [
|
||||
'custom.tag', // Non-matching attributes should be excluded
|
||||
'user.id',
|
||||
'request.id',
|
||||
]);
|
||||
|
||||
// Verify exact call counts to catch unintended multiple invocations
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,13 @@ import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { getFiltersFromResources, updateFilters } from './utils';
|
||||
import { getFiltersFromResources } from './utils';
|
||||
|
||||
const RESOURCE_STARTS_WITH_REGEX = /^(k8s|cloud|host|deployment)/; // regex to filter out resources that start with the specified keywords
|
||||
const RESOURCE_CONTAINS_REGEX = /(env|service|file|container|tenant)/; // regex to filter out resources that contains the spefied keywords
|
||||
|
||||
const useInitialQuery = (log: ILog): Query => {
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
@@ -17,6 +20,16 @@ const useInitialQuery = (log: ILog): Query => {
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
const updateFilters = (filters: TagFilter): TagFilter => ({
|
||||
...filters,
|
||||
items: filters.items.filter(
|
||||
(filterItem) =>
|
||||
filterItem.key?.key &&
|
||||
(RESOURCE_STARTS_WITH_REGEX.test(filterItem.key.key) ||
|
||||
RESOURCE_CONTAINS_REGEX.test(filterItem.key.key)),
|
||||
),
|
||||
});
|
||||
|
||||
const data: Query = {
|
||||
...updatedAllQueriesOperator,
|
||||
builder: {
|
||||
|
||||
@@ -1,36 +1,9 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
const FALLBACK_STARTS_WITH_REGEX = /^(k8s|cloud|host|deployment)/; // regex to filter out resources that start with the specified keywords
|
||||
const FALLBACK_CONTAINS_REGEX = /(env|service|file|container|tenant)/; // regex to filter out resources that contains the specified keywords
|
||||
|
||||
// Priority categories for filter selection
|
||||
// Strategy:
|
||||
// - Always include: service.name, deployment.environment, env, environment
|
||||
// - Select ONE category only: stops at the first category with a matching attribute
|
||||
// - Within category: picks the first available attribute by order
|
||||
// - Order (highest to lowest priority): Kubernetes > Cloud > Host > Container
|
||||
// - Fallback: If no priority match, uses regex-based filtering (excludes the above attributes)
|
||||
const PRIORITY_CATEGORIES = [
|
||||
['k8s.pod.uid', 'k8s.pod.name', 'k8s.deployment.name'],
|
||||
['cloud.resource_id', 'cloud.provider', 'cloud.region'],
|
||||
['host.id', 'host.name'],
|
||||
['container.id', 'container.name'],
|
||||
];
|
||||
|
||||
const SERVICE_AND_ENVIRONMENT_KEYS = [
|
||||
'service.name',
|
||||
'deployment.environment',
|
||||
'env',
|
||||
'environment',
|
||||
];
|
||||
|
||||
export const getFiltersFromResources = (
|
||||
resources: ILog['resources_string'],
|
||||
): TagFilterItem[] =>
|
||||
@@ -47,59 +20,3 @@ export const getFiltersFromResources = (
|
||||
value: resourceValue,
|
||||
};
|
||||
});
|
||||
|
||||
export const isServiceOrEnvironmentAttribute = (key: string): boolean =>
|
||||
SERVICE_AND_ENVIRONMENT_KEYS.includes(key);
|
||||
|
||||
export const getServiceAndEnvironmentFilterItems = (
|
||||
items: TagFilterItem[],
|
||||
): TagFilterItem[] =>
|
||||
items.filter(
|
||||
(item) => item.key?.key && isServiceOrEnvironmentAttribute(item.key.key),
|
||||
);
|
||||
|
||||
export const findFirstPriorityItem = (
|
||||
items: TagFilterItem[],
|
||||
): TagFilterItem | undefined =>
|
||||
PRIORITY_CATEGORIES.flat()
|
||||
.map((priorityKey) => items.find((item) => item.key?.key === priorityKey))
|
||||
.find(Boolean);
|
||||
|
||||
export const getFallbackItems = (items: TagFilterItem[]): TagFilterItem[] =>
|
||||
items.filter((item) => {
|
||||
if (!item.key?.key) return false;
|
||||
|
||||
const { key } = item.key;
|
||||
|
||||
return (
|
||||
FALLBACK_STARTS_WITH_REGEX.test(key) || FALLBACK_CONTAINS_REGEX.test(key)
|
||||
);
|
||||
});
|
||||
|
||||
export const updateFilters = (filters: TagFilter): TagFilter => {
|
||||
const availableItems = filters.items;
|
||||
const selectedItems: TagFilterItem[] = [];
|
||||
|
||||
// Step 1: Always include service.name and environment attributes
|
||||
selectedItems.push(...getServiceAndEnvironmentFilterItems(availableItems));
|
||||
|
||||
// Step 2: Find first category with attributes and pick first available
|
||||
const priorityItem = findFirstPriorityItem(availableItems);
|
||||
if (priorityItem) {
|
||||
selectedItems.push(priorityItem);
|
||||
} else {
|
||||
// Step 3: Fallback to current regex logic (only if no priority items found)
|
||||
const fallbackItems = getFallbackItems(availableItems);
|
||||
if (fallbackItems.length > 0) {
|
||||
selectedItems.push(...fallbackItems);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...filters,
|
||||
// deduplication
|
||||
items: Array.from(
|
||||
new Map(selectedItems.map((item) => [item.key?.key || '', item])).values(),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,15 +5,12 @@ import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { useUpdateMetricMetadata } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Edit2, Save, X } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import {
|
||||
@@ -38,7 +35,6 @@ function Metadata({
|
||||
metricType: metadata?.metric_type || MetricType.SUM,
|
||||
description: metadata?.description || '',
|
||||
temporality: metadata?.temporality,
|
||||
unit: metadata?.unit,
|
||||
});
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
@@ -48,7 +44,6 @@ function Metadata({
|
||||
const [activeKey, setActiveKey] = useState<string | string[]>(
|
||||
'metric-metadata',
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const tableData = useMemo(
|
||||
() =>
|
||||
@@ -70,101 +65,6 @@ function Metadata({
|
||||
[metadata],
|
||||
);
|
||||
|
||||
// Render un-editable field value
|
||||
const renderUneditableField = useCallback((key: string, value: string) => {
|
||||
if (key === 'metric_type') {
|
||||
return <MetricTypeRenderer type={value as MetricType} />;
|
||||
}
|
||||
let fieldValue = value;
|
||||
if (key === 'unit') {
|
||||
fieldValue = getUniversalNameFromMetricUnit(value);
|
||||
}
|
||||
return <FieldRenderer field={fieldValue || '-'} />;
|
||||
}, []);
|
||||
|
||||
const renderColumnValue = useCallback(
|
||||
(field: { value: string; key: string }): JSX.Element => {
|
||||
if (!isEditing) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
// Don't allow editing of unit if it's already set
|
||||
const metricUnitAlreadySet = field.key === 'unit' && Boolean(metadata?.unit);
|
||||
if (metricUnitAlreadySet) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
if (field.key === 'metric_type') {
|
||||
return (
|
||||
<Select
|
||||
data-testid="metric-type-select"
|
||||
options={Object.entries(METRIC_TYPE_VALUES_MAP).map(([key]) => ({
|
||||
value: key,
|
||||
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
|
||||
}))}
|
||||
value={metricMetadata.metricType}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
metricType: value as MetricType,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'unit') {
|
||||
return (
|
||||
<YAxisUnitSelector
|
||||
value={metricMetadata.unit}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({ ...prev, unit: value }));
|
||||
}}
|
||||
data-testid="unit-select"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'temporality') {
|
||||
return (
|
||||
<Select
|
||||
data-testid="temporality-select"
|
||||
options={Object.values(Temporality).map((key) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
}))}
|
||||
value={metricMetadata.temporality}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
temporality: value as Temporality,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'description') {
|
||||
return (
|
||||
<Input
|
||||
data-testid="description-input"
|
||||
name={field.key}
|
||||
defaultValue={
|
||||
metricMetadata[
|
||||
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
|
||||
]
|
||||
}
|
||||
onChange={(e): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
[field.key]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <FieldRenderer field="-" />;
|
||||
},
|
||||
[isEditing, metadata?.unit, metricMetadata, renderUneditableField],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<DataType> = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -190,10 +90,74 @@ function Metadata({
|
||||
align: 'left',
|
||||
ellipsis: true,
|
||||
className: 'metric-metadata-value',
|
||||
render: renderColumnValue,
|
||||
render: (field: { value: string; key: string }): JSX.Element => {
|
||||
if (!isEditing || field.key === 'unit') {
|
||||
if (field.key === 'metric_type') {
|
||||
return (
|
||||
<div>
|
||||
<MetricTypeRenderer type={field.value as MetricType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <FieldRenderer field={field.value || '-'} />;
|
||||
}
|
||||
if (field.key === 'metric_type') {
|
||||
return (
|
||||
<Select
|
||||
data-testid="metric-type-select"
|
||||
options={Object.entries(METRIC_TYPE_VALUES_MAP).map(([key]) => ({
|
||||
value: key,
|
||||
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
|
||||
}))}
|
||||
defaultValue={metricMetadata.metricType}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
metricType: value as MetricType,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'temporality') {
|
||||
return (
|
||||
<Select
|
||||
data-testid="temporality-select"
|
||||
options={Object.values(Temporality).map((key) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
}))}
|
||||
defaultValue={metricMetadata.temporality}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
temporality: value as Temporality,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
data-testid="description-input"
|
||||
name={field.key}
|
||||
defaultValue={
|
||||
metricMetadata[
|
||||
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
|
||||
]
|
||||
}
|
||||
onChange={(e): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
[field.key]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[renderColumnValue],
|
||||
[isEditing, metricMetadata, setMetricMetadata],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -221,7 +185,6 @@ function Metadata({
|
||||
});
|
||||
refetchMetricDetails();
|
||||
setIsEditing(false);
|
||||
queryClient.invalidateQueries(['metricsList']);
|
||||
} else {
|
||||
notifications.error({
|
||||
message:
|
||||
@@ -242,7 +205,6 @@ function Metadata({
|
||||
metricMetadata,
|
||||
notifications,
|
||||
refetchMetricDetails,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const actionButton = useMemo(() => {
|
||||
|
||||
@@ -224,6 +224,10 @@
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.metric-type-renderer {
|
||||
max-height: 12px;
|
||||
}
|
||||
|
||||
.metric-metadata-key {
|
||||
cursor: pointer;
|
||||
padding-left: 10px;
|
||||
@@ -387,11 +391,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metric-metadata-value {
|
||||
.y-axis-unit-selector-component {
|
||||
.ant-select {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,15 +23,11 @@ const mockAlerts = [mockAlert1, mockAlert2];
|
||||
const mockDashboards = [mockDashboard1, mockDashboard2];
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => {
|
||||
const actual = jest.requireActual('hooks/useSafeNavigate');
|
||||
return {
|
||||
...actual,
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
};
|
||||
});
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSetQuery = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
|
||||
@@ -2,76 +2,11 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import {
|
||||
UniversalYAxisUnit,
|
||||
YAxisUnitSelectorProps,
|
||||
} from 'components/YAxisUnitSelector/types';
|
||||
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import * as useNotificationsHooks from 'hooks/useNotifications';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
|
||||
import Metadata from '../Metadata';
|
||||
|
||||
// Mock antd select for testing
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Select: ({
|
||||
children,
|
||||
onChange,
|
||||
value,
|
||||
'data-testid': dataTestId,
|
||||
options,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
'data-testid': string;
|
||||
options: SelectOption<string, string>[];
|
||||
}): JSX.Element => (
|
||||
<select
|
||||
data-testid={dataTestId}
|
||||
value={value}
|
||||
onChange={(e): void => onChange?.(e.target.value)}
|
||||
>
|
||||
{options?.map((option: SelectOption<string, string>) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
jest.mock(
|
||||
'components/YAxisUnitSelector',
|
||||
() =>
|
||||
function MockYAxisUnitSelector({
|
||||
onChange,
|
||||
value,
|
||||
'data-testid': dataTestId,
|
||||
}: YAxisUnitSelectorProps): JSX.Element {
|
||||
return (
|
||||
<select
|
||||
data-testid={dataTestId}
|
||||
value={value}
|
||||
onChange={(e): void => onChange?.(e.target.value as UniversalYAxisUnit)}
|
||||
>
|
||||
<option value="">Please select a unit</option>
|
||||
<option value="By">Bytes (B)</option>
|
||||
<option value="s">Seconds (s)</option>
|
||||
<option value="ms">Milliseconds (ms)</option>
|
||||
</select>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueryClient: (): { invalidateQueries: () => void } => ({
|
||||
invalidateQueries: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseUpdateMetricMetadata = jest.fn();
|
||||
jest
|
||||
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
|
||||
@@ -140,10 +75,7 @@ describe('Metadata', () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metadata={{
|
||||
...mockMetricMetadata,
|
||||
unit: '',
|
||||
}}
|
||||
metadata={mockMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
@@ -158,24 +90,6 @@ describe('Metadata', () => {
|
||||
target: { value: 'Updated description' },
|
||||
});
|
||||
|
||||
const metricTypeSelect = screen.getByTestId('metric-type-select');
|
||||
expect(metricTypeSelect).toBeInTheDocument();
|
||||
fireEvent.change(metricTypeSelect, {
|
||||
target: { value: MetricType.SUM },
|
||||
});
|
||||
|
||||
const temporalitySelect = screen.getByTestId('temporality-select');
|
||||
expect(temporalitySelect).toBeInTheDocument();
|
||||
fireEvent.change(temporalitySelect, {
|
||||
target: { value: Temporality.CUMULATIVE },
|
||||
});
|
||||
|
||||
const unitSelect = screen.getByTestId('unit-select');
|
||||
expect(unitSelect).toBeInTheDocument();
|
||||
fireEvent.change(unitSelect, {
|
||||
target: { value: 'By' },
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText('Save');
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
fireEvent.click(saveButton);
|
||||
@@ -185,10 +99,6 @@ describe('Metadata', () => {
|
||||
metricName: mockMetricName,
|
||||
payload: expect.objectContaining({
|
||||
description: 'Updated description',
|
||||
metricType: MetricType.SUM,
|
||||
temporality: Temporality.CUMULATIVE,
|
||||
unit: 'By',
|
||||
isMonotonic: true,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -309,21 +219,4 @@ describe('Metadata', () => {
|
||||
const editButton2 = screen.getByText('Edit');
|
||||
expect(editButton2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not allow editing of unit if it is already set', () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metadata={mockMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
expect(editButton).toBeInTheDocument();
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const unitSelect = screen.queryByTestId('unit-select');
|
||||
expect(unitSelect).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MetricDetails as MetricDetailsType } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as useGetMetricDetails from 'hooks/metricsExplorer/useGetMetricDetails';
|
||||
import * as useUpdateMetricMetadata from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
@@ -81,12 +80,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueryClient: (): { invalidateQueries: () => void } => ({
|
||||
invalidateQueries: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('MetricDetails', () => {
|
||||
it('renders metric details correctly', () => {
|
||||
@@ -102,9 +95,7 @@ describe('MetricDetails', () => {
|
||||
|
||||
expect(screen.getByText(mockMetricName)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricDescription)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(getUniversalNameFromMetricUnit(mockMetricData.unit)),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(`${mockMetricData.unit}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the "open in explorer" and "inspect" buttons', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { render } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { TreemapViewType } from '../types';
|
||||
@@ -145,7 +144,7 @@ describe('formatDataForMetricsTable', () => {
|
||||
// Verify unit rendering
|
||||
const unitElement = result[0].unit as JSX.Element;
|
||||
const { container: unitWrapper } = render(unitElement);
|
||||
expect(unitWrapper.textContent).toBe(getUniversalNameFromMetricUnit('bytes'));
|
||||
expect(unitWrapper.textContent).toBe('bytes');
|
||||
|
||||
// Verify samples rendering
|
||||
const samplesElement = result[0][TreemapViewType.SAMPLES] as JSX.Element;
|
||||
@@ -163,10 +162,10 @@ describe('formatDataForMetricsTable', () => {
|
||||
it('should handle empty/null values', () => {
|
||||
const mockData = [
|
||||
{
|
||||
metric_name: '',
|
||||
description: '',
|
||||
metric_name: 'test-metric',
|
||||
description: 'test-description',
|
||||
type: MetricType.GAUGE,
|
||||
unit: '',
|
||||
unit: 'ms',
|
||||
[TreemapViewType.SAMPLES]: 0,
|
||||
[TreemapViewType.TIMESERIES]: 0,
|
||||
lastReceived: '2023-01-01T00:00:00Z',
|
||||
@@ -178,17 +177,17 @@ describe('formatDataForMetricsTable', () => {
|
||||
// Verify empty metric name rendering
|
||||
const metricNameElement = result[0].metric_name as JSX.Element;
|
||||
const { container: metricNameWrapper } = render(metricNameElement);
|
||||
expect(metricNameWrapper.textContent).toBe('-');
|
||||
expect(metricNameWrapper.textContent).toBe('test-metric');
|
||||
|
||||
// Verify null description rendering
|
||||
const descriptionElement = result[0].description as JSX.Element;
|
||||
const { container: descriptionWrapper } = render(descriptionElement);
|
||||
expect(descriptionWrapper.textContent).toBe('-');
|
||||
expect(descriptionWrapper.textContent).toBe('test-description');
|
||||
|
||||
// Verify null unit rendering
|
||||
const unitElement = result[0].unit as JSX.Element;
|
||||
const { container: unitWrapper } = render(unitElement);
|
||||
expect(unitWrapper.textContent).toBe('-');
|
||||
expect(unitWrapper.textContent).toBe('ms');
|
||||
|
||||
// Verify zero samples rendering
|
||||
const samplesElement = result[0][TreemapViewType.SAMPLES] as JSX.Element;
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SamplesData,
|
||||
TimeseriesData,
|
||||
} from 'api/metricsExplorer/getMetricsTreeMap';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import {
|
||||
BarChart,
|
||||
BarChart2,
|
||||
@@ -200,8 +199,8 @@ export const formatDataForMetricsTable = (
|
||||
),
|
||||
metric_type: <MetricTypeRenderer type={metric.type} />,
|
||||
unit: (
|
||||
<ValidateRowValueWrapper value={getUniversalNameFromMetricUnit(metric.unit)}>
|
||||
{getUniversalNameFromMetricUnit(metric.unit)}
|
||||
<ValidateRowValueWrapper value={metric.unit}>
|
||||
{metric.unit}
|
||||
</ValidateRowValueWrapper>
|
||||
),
|
||||
[TreemapViewType.SAMPLES]: (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import PanelMarkersControl from 'components/PanelMarkersControl';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -488,6 +489,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
<DashboardVariableSelection />
|
||||
</section>
|
||||
)}
|
||||
<PanelMarkersControl />
|
||||
<DashboardGraphSlider />
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
|
||||
import { Row } from 'antd';
|
||||
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -31,8 +28,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
setVariablesToGetUpdated,
|
||||
} = useDashboard();
|
||||
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
|
||||
const { data } = selectedDashboard || {};
|
||||
|
||||
const { variables } = data || {};
|
||||
@@ -66,11 +61,8 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
tableRowData.sort((a, b) => a.order - b.order);
|
||||
|
||||
setVariablesTableData(tableRowData);
|
||||
|
||||
// Initialize variables with default values if not in URL
|
||||
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
|
||||
}
|
||||
}, [getUrlVariables, updateUrlVariable, variables]);
|
||||
}, [variables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (variablesTableData.length > 0) {
|
||||
@@ -126,12 +118,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
const isDynamic = variable?.type === 'DYNAMIC';
|
||||
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
|
||||
|
||||
if (allSelected) {
|
||||
updateUrlVariable(name || id, ALL_SELECTED_VALUE);
|
||||
} else {
|
||||
updateUrlVariable(name || id, value);
|
||||
}
|
||||
|
||||
if (selectedDashboard) {
|
||||
setSelectedDashboard((prev) => {
|
||||
if (prev) {
|
||||
|
||||
@@ -66,7 +66,6 @@ function PromQLQueryBuilder({
|
||||
defaultValue={queryData?.query}
|
||||
addonBefore="PromQL Query"
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
data-testid="promql-query-input"
|
||||
/>
|
||||
|
||||
<Input
|
||||
@@ -76,7 +75,6 @@ function PromQLQueryBuilder({
|
||||
defaultValue={queryData?.legend}
|
||||
addonBefore="Legend Format"
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
data-testid="promql-legend-input"
|
||||
/>
|
||||
</QueryHeader>
|
||||
);
|
||||
|
||||
@@ -30,21 +30,25 @@ function LeftContainer({
|
||||
enableDrillDown = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
// const { selectedDashboard } = useDashboard();
|
||||
|
||||
const { selectedTime: globalSelectedInterval, minTime, maxTime } = useSelector<
|
||||
const { selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const queryResponse = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
|
||||
enabled: !!stagedQuery,
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedInterval,
|
||||
requestData,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
});
|
||||
const queryResponse = useGetQueryRange(
|
||||
requestData,
|
||||
// selectedDashboard?.data?.version || DEFAULT_ENTITY_VERSION,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
enabled: !!stagedQuery,
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedInterval,
|
||||
requestData,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// Update parent component with query response for legend colors
|
||||
useEffect(() => {
|
||||
|
||||
@@ -43,7 +43,6 @@ function Threshold({
|
||||
tableOptions,
|
||||
thresholdTableOptions = '',
|
||||
columnUnits,
|
||||
yAxisUnit,
|
||||
}: ThresholdProps): JSX.Element {
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(isEditEnabled);
|
||||
const [operator, setOperator] = useState<string | number>(
|
||||
@@ -196,13 +195,16 @@ function Threshold({
|
||||
|
||||
const allowDragAndDrop = panelTypeVsDragAndDrop[selectedGraph];
|
||||
|
||||
const isInvalidUnitComparison = useMemo(() => {
|
||||
const toUnitId =
|
||||
selectedGraph === PANEL_TYPES.TABLE
|
||||
? getColumnUnit(tableSelectedOption, columnUnits || {})
|
||||
: yAxisUnit;
|
||||
return unit !== 'none' && convertUnit(value, unit, toUnitId) === null;
|
||||
}, [selectedGraph, yAxisUnit, tableSelectedOption, columnUnits, unit, value]);
|
||||
const isInvalidUnitComparison = useMemo(
|
||||
() =>
|
||||
unit !== 'none' &&
|
||||
convertUnit(
|
||||
value,
|
||||
unit,
|
||||
getColumnUnit(tableSelectedOption, columnUnits || {}),
|
||||
) === null,
|
||||
[unit, value, columnUnits, tableSelectedOption],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -316,9 +318,7 @@ function Threshold({
|
||||
<Select
|
||||
defaultValue={unit}
|
||||
options={unitOptions(
|
||||
selectedGraph === PANEL_TYPES.TABLE
|
||||
? getColumnUnit(tableSelectedOption, columnUnits || {}) || ''
|
||||
: yAxisUnit || '',
|
||||
getColumnUnit(tableSelectedOption, columnUnits || {}) || '',
|
||||
)}
|
||||
onChange={handleUnitChange}
|
||||
showSearch
|
||||
@@ -357,12 +357,8 @@ function Threshold({
|
||||
</div>
|
||||
{isInvalidUnitComparison && (
|
||||
<Typography.Text className="invalid-unit">
|
||||
Threshold unit ({unit}) is not valid in comparison with the{' '}
|
||||
{selectedGraph === PANEL_TYPES.TABLE ? 'column' : 'y-axis'} unit (
|
||||
{selectedGraph === PANEL_TYPES.TABLE
|
||||
? getColumnUnit(tableSelectedOption, columnUnits || {}) || 'none'
|
||||
: yAxisUnit || 'none'}
|
||||
)
|
||||
Threshold unit ({unit}) is not valid in comparison with the column unit (
|
||||
{getColumnUnit(tableSelectedOption, columnUnits || {}) || 'none'})
|
||||
</Typography.Text>
|
||||
)}
|
||||
{isEditMode && (
|
||||
|
||||
@@ -95,7 +95,6 @@ function ThresholdSelector({
|
||||
tableOptions={aggregationQueries}
|
||||
thresholdTableOptions={threshold.thresholdTableOptions}
|
||||
columnUnits={columnUnits}
|
||||
yAxisUnit={yAxisUnit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import Threshold from '../Threshold';
|
||||
|
||||
// Mock the getColumnUnit function
|
||||
jest.mock('lib/query/createTableColumnsFromQuery', () => ({
|
||||
getColumnUnit: jest.fn(
|
||||
(option: string, columnUnits: Record<string, string>) =>
|
||||
columnUnits[option] || 'percent',
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the unitOptions function
|
||||
jest.mock('container/NewWidget/utils', () => ({
|
||||
unitOptions: jest.fn(() => [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'percent', label: 'Percent' },
|
||||
{ value: 'ms', label: 'Milliseconds' },
|
||||
]),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
index: 'test-threshold-1',
|
||||
keyIndex: 0,
|
||||
thresholdOperator: '>' as const,
|
||||
thresholdValue: 50,
|
||||
thresholdUnit: 'none',
|
||||
thresholdColor: 'Red',
|
||||
thresholdFormat: 'Text' as const,
|
||||
isEditEnabled: true,
|
||||
selectedGraph: PANEL_TYPES.TABLE,
|
||||
tableOptions: [
|
||||
{ value: 'cpu_usage', label: 'CPU Usage' },
|
||||
{ value: 'memory_usage', label: 'Memory Usage' },
|
||||
],
|
||||
thresholdTableOptions: 'cpu_usage',
|
||||
columnUnits: { cpu_usage: 'percent', memory_usage: 'bytes' },
|
||||
yAxisUnit: 'percent',
|
||||
moveThreshold: jest.fn(),
|
||||
};
|
||||
|
||||
const renderThreshold = (props = {}): void => {
|
||||
render(
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Threshold {...{ ...defaultProps, ...props }} />
|
||||
</DndProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('Threshold Component Unit Validation', () => {
|
||||
it('should not show validation error when threshold unit is "none" regardless of column unit', () => {
|
||||
// Act - Render component with "none" threshold unit
|
||||
renderThreshold({
|
||||
thresholdUnit: 'none',
|
||||
thresholdValue: 50,
|
||||
});
|
||||
|
||||
// Assert - No validation error should be displayed
|
||||
expect(
|
||||
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show validation error when threshold unit is not "none" and units are incompatible', () => {
|
||||
// Act - Render component with incompatible units (ms vs percent)
|
||||
renderThreshold({
|
||||
thresholdUnit: 'ms',
|
||||
thresholdValue: 50,
|
||||
});
|
||||
|
||||
// Assert - Validation error should be displayed
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Threshold unit \(ms\) is not valid in comparison with the column unit \(percent\)/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show validation error when threshold unit matches column unit', () => {
|
||||
// Act - Render component with matching units
|
||||
renderThreshold({
|
||||
thresholdUnit: 'percent',
|
||||
thresholdValue: 50,
|
||||
});
|
||||
|
||||
// Assert - No validation error should be displayed
|
||||
expect(
|
||||
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show validation error for time series graph when units are incompatible', () => {
|
||||
// Act - Render component for time series with incompatible units
|
||||
renderThreshold({
|
||||
selectedGraph: PANEL_TYPES.TIME_SERIES,
|
||||
thresholdUnit: 'ms',
|
||||
thresholdValue: 100,
|
||||
yAxisUnit: 'percent',
|
||||
});
|
||||
|
||||
// Assert - Validation error should be displayed
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Threshold unit \(ms\) is not valid in comparison with the y-axis unit \(percent\)/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show validation error for time series graph when threshold unit is "none"', () => {
|
||||
// Act - Render component for time series with "none" threshold unit
|
||||
renderThreshold({
|
||||
selectedGraph: PANEL_TYPES.TIME_SERIES,
|
||||
thresholdUnit: 'none',
|
||||
thresholdValue: 100,
|
||||
yAxisUnit: 'percent',
|
||||
});
|
||||
|
||||
// Assert - No validation error should be displayed
|
||||
expect(
|
||||
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show validation error when threshold unit is compatible with column unit', () => {
|
||||
// Act - Render component with compatible units (both in same category - Time)
|
||||
renderThreshold({
|
||||
thresholdUnit: 's',
|
||||
thresholdValue: 100,
|
||||
columnUnits: { cpu_usage: 'ms' },
|
||||
thresholdTableOptions: 'cpu_usage',
|
||||
});
|
||||
|
||||
// Assert - No validation error should be displayed
|
||||
expect(
|
||||
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show validation error when threshold unit is in different category than column unit', () => {
|
||||
// Act - Render component with units from different categories
|
||||
renderThreshold({
|
||||
thresholdUnit: 'bytes',
|
||||
thresholdValue: 100,
|
||||
yAxisUnit: 'percent',
|
||||
});
|
||||
|
||||
// Assert - Validation error should be displayed
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Threshold unit \(bytes\) is not valid in comparison with the column unit \(percent\)/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,6 @@ export type ThresholdProps = {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
tableOptions?: Array<{ value: string; label: string }>;
|
||||
columnUnits?: ColumnUnit;
|
||||
yAxisUnit?: string;
|
||||
};
|
||||
|
||||
export type ShowCaseValueProps = {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
|
||||
import './UplotPanelWrapper.styles.scss';
|
||||
|
||||
import { Alert } from 'antd';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { verticalMarkersPlugin } from 'components/Uplot/plugins/verticalMarkersPlugin';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
@@ -17,6 +20,7 @@ import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useMarkers } from 'providers/Markers/Markers';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -55,6 +59,32 @@ function UplotPanelWrapper({
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { filteredMarkersData, shouldShowMarkers } = useMarkers();
|
||||
|
||||
const markersPlugin: uPlot.Plugin | null = useMemo(() => {
|
||||
if (shouldShowMarkers) {
|
||||
console.log('*** filteredMarkersData', filteredMarkersData);
|
||||
console.log('*** shouldShowMarkers', {
|
||||
markersData: [
|
||||
{ id: 'm1', val: 1760625000, stroke: 'rgba(96, 255, 128, 0.95)' },
|
||||
{ id: 'm2', val: 1760630000, stroke: 'rgba(255, 96, 96, 0.95)' },
|
||||
{ id: 'm3', val: 1760640000, stroke: 'rgba(255, 96, 96, 0.95)' },
|
||||
],
|
||||
lineType: [6, 4],
|
||||
width: 1,
|
||||
});
|
||||
return verticalMarkersPlugin({
|
||||
markersData: [
|
||||
{ id: 'm1', val: 1760625000, stroke: 'rgba(96, 255, 128, 0.95)' },
|
||||
{ id: 'm2', val: 1760630000, stroke: 'rgba(255, 96, 96, 0.95)' },
|
||||
{ id: 'm3', val: 1760640000, stroke: 'rgba(255, 96, 96, 0.95)' },
|
||||
],
|
||||
lineType: [6, 4],
|
||||
width: 1,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [shouldShowMarkers, filteredMarkersData]);
|
||||
|
||||
const [hiddenGraph, setHiddenGraph] = useState<{ [key: string]: boolean }>();
|
||||
|
||||
@@ -249,6 +279,7 @@ function UplotPanelWrapper({
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
customPlugins: [...(markersPlugin ? [markersPlugin] : [])],
|
||||
}),
|
||||
[
|
||||
queryResponse.data?.payload,
|
||||
@@ -270,6 +301,7 @@ function UplotPanelWrapper({
|
||||
onClickHandler,
|
||||
widget,
|
||||
stackedBarChart,
|
||||
markersPlugin,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { getMockQuery, getMockQueryData } from './testUtils';
|
||||
|
||||
const mockQueryData = getMockQueryData();
|
||||
const mockQuery = getMockQuery();
|
||||
const MOCK_LABEL_NAME = 'mock-label-name';
|
||||
|
||||
describe('getLegend', () => {
|
||||
it('should directly return the label name for clickhouse query', () => {
|
||||
const legendsData = getLegend(
|
||||
mockQueryData,
|
||||
getMockQuery({
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
}),
|
||||
MOCK_LABEL_NAME,
|
||||
);
|
||||
expect(legendsData).toBeDefined();
|
||||
expect(legendsData).toBe(MOCK_LABEL_NAME);
|
||||
});
|
||||
|
||||
it('should directly return the label name for promql query', () => {
|
||||
const legendsData = getLegend(
|
||||
mockQueryData,
|
||||
getMockQuery({
|
||||
queryType: EQueryType.PROM,
|
||||
}),
|
||||
MOCK_LABEL_NAME,
|
||||
);
|
||||
expect(legendsData).toBeDefined();
|
||||
expect(legendsData).toBe(MOCK_LABEL_NAME);
|
||||
});
|
||||
|
||||
it('should return alias when single builder query with single aggregation and alias (logs)', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [{ expression: "sum(bytes) as 'alias_sum'" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe('alias_sum');
|
||||
});
|
||||
|
||||
it('should return legend when single builder query with no alias but legend set (builder)', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
legend: 'custom-legend',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe('custom-legend');
|
||||
});
|
||||
|
||||
it('should return label when grouped by with single aggregation (builder)', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: [
|
||||
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(MOCK_LABEL_NAME);
|
||||
});
|
||||
|
||||
it("should return '<alias>-<label>' when grouped by with multiple aggregations (builder)", () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [
|
||||
{ expression: "sum(bytes) as 'sum_b'" },
|
||||
{ expression: 'count()' },
|
||||
],
|
||||
groupBy: [
|
||||
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(`sum_b-${MOCK_LABEL_NAME}`);
|
||||
});
|
||||
|
||||
it('should fallback to label or query name when no alias/expression', () => {
|
||||
const legendsData = getLegend(mockQueryData, mockQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(MOCK_LABEL_NAME);
|
||||
});
|
||||
|
||||
it('should return alias when single query with multiple aggregations and no group by', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [
|
||||
{ expression: "sum(bytes) as 'total'" },
|
||||
{ expression: 'count()' },
|
||||
],
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe('total');
|
||||
});
|
||||
|
||||
it("should return '<alias>-<label>' when multiple queries with group by", () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [
|
||||
{ expression: "sum(bytes) as 'sum_b'" },
|
||||
{ expression: 'count()' },
|
||||
],
|
||||
groupBy: [
|
||||
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
},
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: 'B',
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(`sum_b-${MOCK_LABEL_NAME}`);
|
||||
});
|
||||
|
||||
it('should return label according to the index of the query', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [
|
||||
{ expression: "sum(bytes) as 'sum_a'" },
|
||||
{ expression: 'count()' },
|
||||
],
|
||||
groupBy: [
|
||||
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
},
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: 'B',
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(
|
||||
{
|
||||
...mockQueryData,
|
||||
metaData: {
|
||||
...mockQueryData.metaData,
|
||||
index: 1,
|
||||
},
|
||||
} as QueryData,
|
||||
payloadQuery,
|
||||
MOCK_LABEL_NAME,
|
||||
);
|
||||
expect(legendsData).toBe(`count()-${MOCK_LABEL_NAME}`);
|
||||
});
|
||||
|
||||
it('should handle trace operator with multiple queries and group by', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
queryTraceOperator: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregations: [
|
||||
{ expression: "count() as 'total_count' avg(duration_nano)" },
|
||||
],
|
||||
groupBy: [
|
||||
{ key: 'service.name', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
expression: 'A',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(`total_count-${MOCK_LABEL_NAME}`);
|
||||
});
|
||||
|
||||
it('should handle single trace operator query with group by', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [],
|
||||
queryTraceOperator: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregations: [{ expression: "count() as 'total' avg(duration_nano)" }],
|
||||
groupBy: [
|
||||
{ key: 'service.name', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
expression: 'A && B',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(`total-${MOCK_LABEL_NAME}`);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { initialQueryState } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export function getMockQueryData(): QueryData {
|
||||
return {
|
||||
lowerBoundSeries: [],
|
||||
upperBoundSeries: [],
|
||||
predictedSeries: [],
|
||||
anomalyScores: [],
|
||||
metric: {},
|
||||
queryName: 'test-query-name',
|
||||
legend: 'test-legend',
|
||||
values: [],
|
||||
quantity: [],
|
||||
unit: 'test-unit',
|
||||
table: {
|
||||
rows: [],
|
||||
columns: [],
|
||||
},
|
||||
metaData: {
|
||||
alias: 'test-alias',
|
||||
index: 0,
|
||||
queryName: 'test-query-name',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockQuery(overrides?: Partial<Query>): Query {
|
||||
return {
|
||||
...initialQueryState,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -9,5 +9,4 @@ export type AgregatorFilterProps = Pick<AutoCompleteProps, 'disabled'> & {
|
||||
onSelect?: (value: BaseAutocompleteData) => void;
|
||||
index?: number;
|
||||
signalSource?: 'meter' | '';
|
||||
setAttributeKeys?: (keys: BaseAutocompleteData[]) => void;
|
||||
};
|
||||
|
||||
@@ -37,7 +37,6 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
onSelect,
|
||||
index,
|
||||
signalSource,
|
||||
setAttributeKeys,
|
||||
}: AgregatorFilterProps): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
|
||||
@@ -98,7 +97,6 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
})) || [];
|
||||
|
||||
setOptionsData(options);
|
||||
setAttributeKeys?.(data?.payload?.attributeKeys || []);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -137,7 +135,6 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
onChange,
|
||||
index,
|
||||
query,
|
||||
setAttributeKeys,
|
||||
]);
|
||||
|
||||
const handleSearchText = useCallback((text: string): void => {
|
||||
@@ -156,25 +153,23 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
return 'Aggregate attribute';
|
||||
}, [signalSource, query.dataSource]);
|
||||
|
||||
const getAttributesData = useCallback((): BaseAutocompleteData[] => {
|
||||
const attributeKeys =
|
||||
const getAttributesData = useCallback(
|
||||
(): BaseAutocompleteData[] =>
|
||||
queryClient.getQueryData<SuccessResponse<IQueryAutocompleteResponse>>([
|
||||
QueryBuilderKeys.GET_AGGREGATE_ATTRIBUTE,
|
||||
debouncedValue,
|
||||
queryAggregation.timeAggregation,
|
||||
query.dataSource,
|
||||
index,
|
||||
])?.payload?.attributeKeys || [];
|
||||
setAttributeKeys?.(attributeKeys);
|
||||
return attributeKeys;
|
||||
}, [
|
||||
debouncedValue,
|
||||
queryAggregation.timeAggregation,
|
||||
query.dataSource,
|
||||
queryClient,
|
||||
index,
|
||||
setAttributeKeys,
|
||||
]);
|
||||
])?.payload?.attributeKeys || [],
|
||||
[
|
||||
debouncedValue,
|
||||
queryAggregation.timeAggregation,
|
||||
query.dataSource,
|
||||
queryClient,
|
||||
index,
|
||||
],
|
||||
);
|
||||
|
||||
const getResponseAttributes = useCallback(async () => {
|
||||
const response = await queryClient.fetchQuery(
|
||||
@@ -193,7 +188,6 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
}),
|
||||
);
|
||||
|
||||
setAttributeKeys?.(response.payload?.attributeKeys || []);
|
||||
return response.payload?.attributeKeys || [];
|
||||
}, [
|
||||
queryAggregation.timeAggregation,
|
||||
@@ -201,7 +195,6 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
queryClient,
|
||||
searchText,
|
||||
index,
|
||||
setAttributeKeys,
|
||||
]);
|
||||
|
||||
const handleChangeCustomValue = useCallback(
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('TableDrilldown', () => {
|
||||
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
decodeURIComponent(urlObj.searchParams.get('compositeQuery') || '{}'),
|
||||
urlObj.searchParams.get('compositeQuery') || '{}',
|
||||
);
|
||||
|
||||
// Verify the query structure includes the filters from clicked data
|
||||
@@ -270,7 +270,7 @@ describe('TableDrilldown', () => {
|
||||
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
decodeURIComponent(urlObj.searchParams.get('compositeQuery') || '{}'),
|
||||
urlObj.searchParams.get('compositeQuery') || '{}',
|
||||
);
|
||||
// Verify the query structure includes the filters from clicked data
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
|
||||
@@ -137,7 +137,7 @@ const useBaseAggregateOptions = ({
|
||||
);
|
||||
|
||||
let queryParams = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
[QueryParams.compositeQuery]: JSON.stringify(viewQuery),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange?.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange?.endTime.toString(),
|
||||
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
@@ -32,6 +30,8 @@ import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { useSpanContextLogs } from './useSpanContextLogs';
|
||||
|
||||
interface SpanLogsProps {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
@@ -39,29 +39,29 @@ interface SpanLogsProps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
logs: ILog[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
handleExplorerPageRedirect: () => void;
|
||||
emptyStateConfig?: EmptyLogsListConfig;
|
||||
}
|
||||
|
||||
function SpanLogs({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
handleExplorerPageRedirect,
|
||||
emptyStateConfig,
|
||||
}: SpanLogsProps): JSX.Element {
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
} = useSpanContextLogs({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
});
|
||||
|
||||
// Create trace_id and span_id filters for logs explorer navigation
|
||||
const createLogsFilter = useCallback(
|
||||
(targetSpanId: string): TagFilter => {
|
||||
@@ -236,7 +236,9 @@ function SpanLogs({
|
||||
<img src="/Icons/no-data.svg" alt="no-data" className="no-data-img" />
|
||||
<Typography.Text className="no-data-text-1">
|
||||
No logs found for selected span.
|
||||
<span className="no-data-text-2">View logs for the current trace.</span>
|
||||
<span className="no-data-text-2">
|
||||
Try viewing logs for the current trace.
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className="action-section">
|
||||
@@ -247,45 +249,24 @@ function SpanLogs({
|
||||
onClick={handleExplorerPageRedirect}
|
||||
size="md"
|
||||
>
|
||||
View Logs
|
||||
Log Explorer
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSpanLogsContent = (): JSX.Element | null => {
|
||||
if (isLoading || isFetching) {
|
||||
return <LogsLoading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <LogsError />;
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
if (emptyStateConfig) {
|
||||
return (
|
||||
<EmptyLogsSearch
|
||||
dataSource={DataSource.LOGS}
|
||||
panelType="LIST"
|
||||
customMessage={emptyStateConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return renderNoLogsFound();
|
||||
}
|
||||
|
||||
return renderContent;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx('span-logs', { 'span-logs-empty': logs.length === 0 })}>
|
||||
{renderSpanLogsContent()}
|
||||
{(isLoading || isFetching) && <LogsLoading />}
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
logs.length === 0 &&
|
||||
renderNoLogsFound()}
|
||||
{isError && !isLoading && !isFetching && <LogsError />}
|
||||
{!isLoading && !isFetching && !isError && logs.length > 0 && renderContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SpanLogs.defaultProps = {
|
||||
emptyStateConfig: undefined,
|
||||
};
|
||||
|
||||
export default SpanLogs;
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import SpanLogs from '../SpanLogs';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock window.open
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
});
|
||||
|
||||
// Mock Virtuoso to avoid complex virtualization
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
Virtuoso: jest.fn(({ data, itemContent }: any) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data?.map((item: any, index: number) => (
|
||||
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock RawLogView component
|
||||
jest.mock(
|
||||
'components/Logs/RawLogView',
|
||||
() =>
|
||||
function MockRawLogView({
|
||||
data,
|
||||
onLogClick,
|
||||
isHighlighted,
|
||||
helpTooltip,
|
||||
}: any): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`raw-log-${data.id}`}
|
||||
className={isHighlighted ? 'log-highlighted' : 'log-context'}
|
||||
title={helpTooltip}
|
||||
onClick={(e): void => onLogClick?.(data, e)}
|
||||
>
|
||||
<div>{data.body}</div>
|
||||
<div>{data.timestamp}</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock PreferenceContextProvider
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({ children }: any): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
default: ({ children }: any): JSX.Element => (
|
||||
<div data-testid="overlay-scrollbar">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock LogsLoading component
|
||||
jest.mock('container/LogsLoading/LogsLoading', () => ({
|
||||
LogsLoading: function MockLogsLoading(): JSX.Element {
|
||||
return <div data-testid="logs-loading">Loading logs...</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock LogsError component
|
||||
jest.mock(
|
||||
'container/LogsError/LogsError',
|
||||
() =>
|
||||
function MockLogsError(): JSX.Element {
|
||||
return <div data-testid="logs-error">Error loading logs</div>;
|
||||
},
|
||||
);
|
||||
|
||||
// Don't mock EmptyLogsSearch - test the actual component behavior
|
||||
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
|
||||
const defaultProps = {
|
||||
traceId: TEST_TRACE_ID,
|
||||
spanId: TEST_SPAN_ID,
|
||||
timeRange: {
|
||||
startTime: 1640995200000,
|
||||
endTime: 1640995260000,
|
||||
},
|
||||
logs: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLogSpanRelated: jest.fn().mockReturnValue(false),
|
||||
handleExplorerPageRedirect: jest.fn(),
|
||||
};
|
||||
|
||||
describe('SpanLogs', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockWindowOpen.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should show simple empty state when emptyStateConfig is not provided', () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<SpanLogs {...defaultProps} />);
|
||||
|
||||
// Should show simple empty state (no emptyStateConfig provided)
|
||||
expect(
|
||||
screen.getByText('No logs found for selected span.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('View logs for the current trace.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should NOT show enhanced empty state
|
||||
expect(screen.queryByTestId('empty-logs-search')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('documentation-links')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show enhanced empty state when entire trace has no logs', () => {
|
||||
render(
|
||||
<SpanLogs
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...defaultProps}
|
||||
emptyStateConfig={getEmptyLogsListConfig(jest.fn())}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show enhanced empty state with custom message
|
||||
expect(screen.getByText('No logs found for this trace.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This could be because :')).toBeInTheDocument();
|
||||
|
||||
// Should show description list
|
||||
expect(
|
||||
screen.getByText('Logs are not linked to Traces.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Logs are not being sent to SigNoz.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('No logs are associated with this particular trace/span.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should show documentation links
|
||||
expect(screen.getByText('RESOURCES')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sending logs to SigNoz')).toBeInTheDocument();
|
||||
expect(screen.getByText('Correlate traces and logs')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show simple empty state
|
||||
expect(
|
||||
screen.queryByText('No logs found for selected span.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleExplorerPageRedirect when Log Explorer button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockHandleExplorerPageRedirect = jest.fn();
|
||||
|
||||
render(
|
||||
<SpanLogs
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...defaultProps}
|
||||
handleExplorerPageRedirect={mockHandleExplorerPageRedirect}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logExplorerButton = screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
});
|
||||
await user.click(logExplorerButton);
|
||||
|
||||
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,7 @@ export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
},
|
||||
op: '=',
|
||||
op: 'in',
|
||||
value: traceId,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,7 +11,7 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { getSpanLogsQueryPayload, getTraceOnlyFilters } from './constants';
|
||||
import { getSpanLogsQueryPayload } from './constants';
|
||||
|
||||
interface UseSpanContextLogsProps {
|
||||
traceId: string;
|
||||
@@ -20,7 +20,6 @@ interface UseSpanContextLogsProps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isDrawerOpen?: boolean;
|
||||
}
|
||||
|
||||
interface UseSpanContextLogsReturn {
|
||||
@@ -30,7 +29,6 @@ interface UseSpanContextLogsReturn {
|
||||
isFetching: boolean;
|
||||
spanLogIds: Set<string>;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
hasTraceIdLogs: boolean;
|
||||
}
|
||||
|
||||
const traceIdKey = {
|
||||
@@ -112,7 +110,6 @@ export const useSpanContextLogs = ({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
isDrawerOpen = true,
|
||||
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
|
||||
const [allLogs, setAllLogs] = useState<ILog[]>([]);
|
||||
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
|
||||
@@ -267,43 +264,6 @@ export const useSpanContextLogs = ({
|
||||
setAllLogs(combined);
|
||||
}, [beforeLogs, spanLogs, afterLogs]);
|
||||
|
||||
// Phase 4: Check for trace_id-only logs when span has no logs
|
||||
// This helps differentiate between "no logs for span" vs "no logs for trace"
|
||||
const traceOnlyFilter = useMemo(() => {
|
||||
if (spanLogs.length > 0) return null;
|
||||
const filters = getTraceOnlyFilters(traceId);
|
||||
return convertFiltersToExpression(filters);
|
||||
}, [traceId, spanLogs.length]);
|
||||
|
||||
const traceOnlyQueryPayload = useMemo(() => {
|
||||
if (!traceOnlyFilter) return null;
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
traceOnlyFilter,
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, traceOnlyFilter]);
|
||||
|
||||
const { data: traceOnlyData } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.TRACE_ONLY_LOGS,
|
||||
traceId,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
const hasTraceIdLogs = useMemo(() => {
|
||||
if (spanLogs.length > 0) return true;
|
||||
return !!(
|
||||
traceOnlyData?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0
|
||||
);
|
||||
}, [spanLogs.length, traceOnlyData]);
|
||||
|
||||
// Helper function to check if a log belongs to the span
|
||||
const isLogSpanRelated = useCallback(
|
||||
(logId: string): boolean => spanLogIds.has(logId),
|
||||
@@ -317,6 +277,5 @@ export const useSpanContextLogs = ({
|
||||
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
|
||||
spanLogIds,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,8 +37,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.open-in-explorer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
@@ -11,20 +11,39 @@ import {
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Compass, X } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import { RelatedSignalsViews } from '../constants';
|
||||
import SpanLogs from '../SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from '../SpanLogs/useSpanContextLogs';
|
||||
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
|
||||
interface AppliedFiltersProps {
|
||||
filters: TagFilterItem[];
|
||||
}
|
||||
|
||||
function AppliedFilters({ filters }: AppliedFiltersProps): JSX.Element {
|
||||
return (
|
||||
<div className="span-related-signals-drawer__applied-filters">
|
||||
<div className="span-related-signals-drawer__filters-list">
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.id} className="span-related-signals-drawer__filter-tag">
|
||||
<Typography.Text>
|
||||
{filter.key?.key}={filter.value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpanRelatedSignalsProps {
|
||||
selectedSpan: Span;
|
||||
traceStartTime: number;
|
||||
@@ -47,23 +66,6 @@ function SpanRelatedSignals({
|
||||
);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const {
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
} = useSpanContextLogs({
|
||||
traceId: selectedSpan.traceId,
|
||||
spanId: selectedSpan.spanId,
|
||||
timeRange: {
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
},
|
||||
isDrawerOpen: isOpen,
|
||||
});
|
||||
|
||||
const handleTabChange = useCallback((e: RadioChangeEvent): void => {
|
||||
setSelectedView(e.target.value);
|
||||
}, []);
|
||||
@@ -73,6 +75,25 @@ function SpanRelatedSignals({
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const appliedFilters = useMemo(
|
||||
(): TagFilterItem[] => [
|
||||
{
|
||||
id: 'trace-id-filter',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
id: 'trace-id-key',
|
||||
dataType: 'string' as const,
|
||||
isColumn: true,
|
||||
type: '',
|
||||
isJSON: false,
|
||||
} as BaseAutocompleteData,
|
||||
op: '=',
|
||||
value: selectedSpan.traceId,
|
||||
},
|
||||
],
|
||||
[selectedSpan.traceId],
|
||||
);
|
||||
|
||||
const handleExplorerPageRedirect = useCallback((): void => {
|
||||
const startTimeMs = traceStartTime - FIVE_MINUTES_IN_MS;
|
||||
const endTimeMs = traceEndTime + FIVE_MINUTES_IN_MS;
|
||||
@@ -125,14 +146,6 @@ function SpanRelatedSignals({
|
||||
);
|
||||
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
|
||||
|
||||
const emptyStateConfig = useMemo(
|
||||
() => ({
|
||||
...getEmptyLogsListConfig(() => {}),
|
||||
showClearFiltersButton: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="50%"
|
||||
@@ -197,28 +210,23 @@ function SpanRelatedSignals({
|
||||
icon={<Compass size={18} />}
|
||||
className="open-in-explorer"
|
||||
onClick={handleExplorerPageRedirect}
|
||||
>
|
||||
Open in Logs Explorer
|
||||
</Button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedView === RelatedSignalsViews.LOGS && (
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.traceId}
|
||||
spanId={selectedSpan.spanId}
|
||||
timeRange={{
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isFetching={isFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyStateConfig : undefined}
|
||||
/>
|
||||
<>
|
||||
<AppliedFilters filters={appliedFilters} />
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.traceId}
|
||||
spanId={selectedSpan.spanId}
|
||||
timeRange={{
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
expectedAfterFilterExpression,
|
||||
expectedBeforeFilterExpression,
|
||||
expectedSpanFilterExpression,
|
||||
expectedTraceOnlyFilterExpression,
|
||||
mockAfterLogsResponse,
|
||||
mockBeforeLogsResponse,
|
||||
mockEmptyLogsResponse,
|
||||
@@ -218,22 +217,19 @@ const renderSpanDetailsDrawer = (props = {}): void => {
|
||||
};
|
||||
|
||||
describe('SpanDetailsDrawer', () => {
|
||||
let apiCallHistory: any = {};
|
||||
let apiCallHistory: any[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
apiCallHistory = {
|
||||
span_logs: null,
|
||||
before_logs: null,
|
||||
after_logs: null,
|
||||
trace_only_logs: null,
|
||||
};
|
||||
apiCallHistory = [];
|
||||
mockSafeNavigate.mockClear();
|
||||
mockWindowOpen.mockClear();
|
||||
mockUpdateAllQueriesOperators.mockClear();
|
||||
|
||||
// Setup API call tracking
|
||||
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
|
||||
apiCallHistory.push(query);
|
||||
|
||||
// Determine response based on v5 filter expressions
|
||||
const filterExpression =
|
||||
query.query?.builder?.queryData?.[0]?.filter?.expression;
|
||||
@@ -242,23 +238,14 @@ describe('SpanDetailsDrawer', () => {
|
||||
|
||||
// Check for span logs query (contains both trace_id and span_id)
|
||||
if (filterExpression.includes('span_id')) {
|
||||
apiCallHistory.span_logs = query;
|
||||
return Promise.resolve(mockSpanLogsResponse);
|
||||
}
|
||||
// Check for before logs query (contains trace_id and id <)
|
||||
if (filterExpression.includes('id <')) {
|
||||
apiCallHistory.before_logs = query;
|
||||
return Promise.resolve(mockBeforeLogsResponse);
|
||||
}
|
||||
// Check for after logs query (contains trace_id and id >)
|
||||
if (filterExpression.includes('id >')) {
|
||||
apiCallHistory.after_logs = query;
|
||||
return Promise.resolve(mockAfterLogsResponse);
|
||||
}
|
||||
|
||||
// Check for trace only logs query (contains trace_id)
|
||||
if (filterExpression.includes('trace_id =')) {
|
||||
apiCallHistory.trace_only_logs = query;
|
||||
return Promise.resolve(mockAfterLogsResponse);
|
||||
}
|
||||
|
||||
@@ -300,7 +287,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should make 4 API queries when logs tab is opened', async () => {
|
||||
it('should make three API queries when logs tab is opened', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on logs tab to trigger API calls
|
||||
@@ -309,16 +296,11 @@ describe('SpanDetailsDrawer', () => {
|
||||
|
||||
// Wait for all API calls to complete
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
// Verify the four distinct queries were made
|
||||
const {
|
||||
span_logs: spanQuery,
|
||||
before_logs: beforeQuery,
|
||||
after_logs: afterQuery,
|
||||
trace_only_logs: traceOnlyQuery,
|
||||
} = apiCallHistory;
|
||||
// Verify the three distinct queries were made
|
||||
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
|
||||
|
||||
// 1. Span logs query (trace_id + span_id)
|
||||
expect(spanQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||
@@ -334,11 +316,6 @@ describe('SpanDetailsDrawer', () => {
|
||||
expect(afterQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||
expectedAfterFilterExpression,
|
||||
);
|
||||
|
||||
// 4. Trace only logs query (trace_id)
|
||||
expect(traceOnlyQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||
expectedTraceOnlyFilterExpression,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use correct timestamp ordering for different query types', async () => {
|
||||
@@ -350,14 +327,10 @@ describe('SpanDetailsDrawer', () => {
|
||||
|
||||
// Wait for all API calls to complete
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
const {
|
||||
span_logs: spanQuery,
|
||||
before_logs: beforeQuery,
|
||||
after_logs: afterQuery,
|
||||
} = apiCallHistory;
|
||||
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
|
||||
|
||||
// Verify ordering: span query should use 'desc' (default)
|
||||
expect(spanQuery.query.builder.queryData[0].orderBy[0].order).toBe('desc');
|
||||
@@ -490,6 +463,24 @@ describe('SpanDetailsDrawer', () => {
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty logs state', async () => {
|
||||
// Mock empty response for all queries
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockEmptyLogsResponse);
|
||||
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Open logs view
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait and verify empty state is shown
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/No logs found for selected span/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display span logs as highlighted and context logs as regular', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
@@ -499,7 +490,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
|
||||
// Wait for all API calls to complete first
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
// Wait for all logs to be rendered - both span logs and context logs
|
||||
|
||||
@@ -12,7 +12,7 @@ export const mockSpan: Span = {
|
||||
traceId: TEST_TRACE_ID,
|
||||
name: TEST_SERVICE,
|
||||
serviceName: TEST_SERVICE,
|
||||
timestamp: 1640995200000, // 2022-01-01 00:00:00 in milliseconds
|
||||
timestamp: 1640995200000000, // 2022-01-01 00:00:00 in microseconds
|
||||
durationNano: 1000000000, // 1 second in nanoseconds
|
||||
spanKind: 'server',
|
||||
statusCodeString: 'STATUS_CODE_OK',
|
||||
@@ -207,4 +207,3 @@ export const mockEmptyLogsResponse = {
|
||||
export const expectedSpanFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND span_id = '${TEST_SPAN_ID}'`;
|
||||
export const expectedBeforeFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id < 'span-log-1'`;
|
||||
export const expectedAfterFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id > 'span-log-2'`;
|
||||
export const expectedTraceOnlyFilterExpression = `trace_id = '${TEST_TRACE_ID}'`;
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import useVariablesFromUrl, {
|
||||
LocalStoreDashboardVariables,
|
||||
} from '../useVariablesFromUrl';
|
||||
|
||||
describe('useVariablesFromUrl', () => {
|
||||
it('should initialize with empty variables when no URL params exist', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/'],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current.getUrlVariables()).toEqual({});
|
||||
});
|
||||
|
||||
it('should correctly parse variables from URL', () => {
|
||||
const mockVariables = {
|
||||
var1: 'value1',
|
||||
var2: ['value2', 'value3'],
|
||||
var3: 123,
|
||||
};
|
||||
|
||||
const encodedVariables = encodeURIComponent(JSON.stringify(mockVariables));
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current.getUrlVariables()).toEqual(mockVariables);
|
||||
});
|
||||
|
||||
it('should handle malformed URL parameters gracefully', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: [`/?${QueryParams.variables}=invalid-json`],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
// Should return empty object when JSON parsing fails
|
||||
expect(result.current.getUrlVariables()).toEqual({});
|
||||
});
|
||||
|
||||
it('should set variables to URL correctly', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/'],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
const mockVariables: LocalStoreDashboardVariables = {
|
||||
var1: 'value1',
|
||||
var2: ['value2', 'value3'],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setUrlVariables(mockVariables);
|
||||
});
|
||||
|
||||
// Check if the URL was updated correctly
|
||||
const searchParams = new URLSearchParams(history.location.search);
|
||||
const urlVariables = searchParams.get(QueryParams.variables);
|
||||
|
||||
expect(urlVariables).toBeTruthy();
|
||||
expect(JSON.parse(decodeURIComponent(urlVariables || ''))).toEqual(
|
||||
mockVariables,
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove variables param from URL when empty object is provided', () => {
|
||||
const mockVariables = {
|
||||
var1: 'value1',
|
||||
var2: ['value2', 'value3'],
|
||||
};
|
||||
|
||||
const encodedVariables = encodeURIComponent(JSON.stringify(mockVariables));
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setUrlVariables({});
|
||||
});
|
||||
|
||||
// Check if the URL param was removed
|
||||
const searchParams = new URLSearchParams(history.location.search);
|
||||
expect(searchParams.has(QueryParams.variables)).toBe(false);
|
||||
});
|
||||
|
||||
it('should update a specific variable correctly', () => {
|
||||
const initialVariables = {
|
||||
var1: 'value1',
|
||||
var2: ['value2', 'value3'],
|
||||
};
|
||||
|
||||
const encodedVariables = encodeURIComponent(JSON.stringify(initialVariables));
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
const newValue: IDashboardVariable['selectedValue'] = 'updated-value';
|
||||
|
||||
act(() => {
|
||||
result.current.updateUrlVariable('var1', newValue);
|
||||
});
|
||||
|
||||
// Check if only the specified variable was updated
|
||||
const updatedVariables = result.current.getUrlVariables();
|
||||
expect(updatedVariables.var1).toEqual(newValue);
|
||||
expect(updatedVariables.var2).toEqual(initialVariables.var2);
|
||||
});
|
||||
|
||||
it('should preserve other URL parameters when updating variables', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/?otherParam=value'],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
const mockVariables: LocalStoreDashboardVariables = {
|
||||
var1: 'value1',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setUrlVariables(mockVariables);
|
||||
});
|
||||
|
||||
// Check if other params are preserved
|
||||
const searchParams = new URLSearchParams(history.location.search);
|
||||
expect(searchParams.get('otherParam')).toBe('value');
|
||||
expect(searchParams.has(QueryParams.variables)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle different variable value types correctly', () => {
|
||||
const mockVariables: LocalStoreDashboardVariables = {
|
||||
stringVar: 'production',
|
||||
numberVar: 123,
|
||||
booleanVar: true,
|
||||
arrayVar: ['service1', 'service2'],
|
||||
mixedArrayVar: ['string', 456, false],
|
||||
nullVar: null,
|
||||
};
|
||||
|
||||
const encodedVariables = encodeURIComponent(JSON.stringify(mockVariables));
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
const urlVariables = result.current.getUrlVariables();
|
||||
expect(urlVariables.stringVar).toBe('production');
|
||||
expect(urlVariables.numberVar).toBe(123);
|
||||
expect(urlVariables.booleanVar).toBe(true);
|
||||
expect(urlVariables.arrayVar).toEqual(['service1', 'service2']);
|
||||
expect(urlVariables.mixedArrayVar).toEqual(['string', 456, false]);
|
||||
expect(urlVariables.nullVar).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle edge cases in URL variable parsing', () => {
|
||||
const edgeCaseVariables = {
|
||||
emptyString: '',
|
||||
emptyArray: [],
|
||||
singleItemArray: ['solo'],
|
||||
};
|
||||
|
||||
const encodedVariables = encodeURIComponent(
|
||||
JSON.stringify(edgeCaseVariables),
|
||||
);
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
const urlVariables = result.current.getUrlVariables();
|
||||
expect(urlVariables.emptyString).toBe('');
|
||||
expect(urlVariables.emptyArray).toEqual([]);
|
||||
expect(urlVariables.singleItemArray).toEqual(['solo']);
|
||||
expect(urlVariables.undefinedVar).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update variables with array values correctly', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/'],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVariablesFromUrl(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<Router history={history}>{children}</Router>
|
||||
),
|
||||
});
|
||||
|
||||
const arrayValue: IDashboardVariable['selectedValue'] = [
|
||||
'value1',
|
||||
'value2',
|
||||
'value3',
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.updateUrlVariable('multiSelectVar', arrayValue);
|
||||
});
|
||||
|
||||
const updatedVariables = result.current.getUrlVariables();
|
||||
expect(updatedVariables.multiSelectVar).toEqual(arrayValue);
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
export interface LocalStoreDashboardVariables {
|
||||
[name: string]:
|
||||
| IDashboardVariable['selectedValue'][]
|
||||
| IDashboardVariable['selectedValue'];
|
||||
}
|
||||
|
||||
interface UseVariablesFromUrlReturn {
|
||||
getUrlVariables: () => LocalStoreDashboardVariables;
|
||||
setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
|
||||
updateUrlVariable: (
|
||||
name: string,
|
||||
selectedValue: IDashboardVariable['selectedValue'],
|
||||
) => void;
|
||||
clearUrlVariables: () => void;
|
||||
}
|
||||
|
||||
const useVariablesFromUrl = (): UseVariablesFromUrlReturn => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const history = useHistory();
|
||||
|
||||
const getUrlVariables = useCallback((): LocalStoreDashboardVariables => {
|
||||
const variablesParam = urlQuery.get(QueryParams.variables);
|
||||
|
||||
if (!variablesParam) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(variablesParam));
|
||||
} catch (error) {
|
||||
Sentry.captureEvent({
|
||||
message: `Failed to parse dashboard variables from URL: ${error}`,
|
||||
level: 'error',
|
||||
});
|
||||
return {};
|
||||
}
|
||||
}, [urlQuery]);
|
||||
|
||||
const setUrlVariables = useCallback(
|
||||
(variables: LocalStoreDashboardVariables): void => {
|
||||
const params = new URLSearchParams(urlQuery.toString());
|
||||
|
||||
if (Object.keys(variables).length === 0) {
|
||||
params.delete(QueryParams.variables);
|
||||
} else {
|
||||
try {
|
||||
const encodedVariables = encodeURIComponent(JSON.stringify(variables));
|
||||
params.set(QueryParams.variables, encodedVariables);
|
||||
} catch (error) {
|
||||
Sentry.captureEvent({
|
||||
message: `Failed to serialize dashboard variables for URL: ${error}`,
|
||||
level: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
history.replace({
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[history, urlQuery],
|
||||
);
|
||||
|
||||
const clearUrlVariables = useCallback((): void => {
|
||||
const params = new URLSearchParams(urlQuery.toString());
|
||||
params.delete(QueryParams.variables);
|
||||
|
||||
history.replace({
|
||||
search: params.toString(),
|
||||
});
|
||||
}, [history, urlQuery]);
|
||||
|
||||
const updateUrlVariable = useCallback(
|
||||
(name: string, selectedValue: IDashboardVariable['selectedValue']): void => {
|
||||
const currentVariables = getUrlVariables();
|
||||
|
||||
const updatedVariables = {
|
||||
...currentVariables,
|
||||
[name]: selectedValue,
|
||||
};
|
||||
|
||||
setUrlVariables(updatedVariables as LocalStoreDashboardVariables);
|
||||
},
|
||||
[getUrlVariables, setUrlVariables],
|
||||
);
|
||||
|
||||
return {
|
||||
getUrlVariables,
|
||||
setUrlVariables,
|
||||
updateUrlVariable,
|
||||
clearUrlVariables,
|
||||
};
|
||||
};
|
||||
|
||||
export default useVariablesFromUrl;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
@@ -33,14 +33,6 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
} as BaseAutocompleteData,
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
spaceAggregation: '',
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: 'test_metric',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
having: [],
|
||||
limit: null,
|
||||
queryName: 'test_query',
|
||||
@@ -139,111 +131,5 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve aggregation operators when metric type remains the same (GAUGE to GAUGE)', () => {
|
||||
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
|
||||
const newAttribute: BaseAutocompleteData = {
|
||||
key: 'new_gauge_metric',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.GAUGE,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(newAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregateAttribute: newAttribute,
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: 'new_gauge_metric',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset aggregation operators when metric type changes (GAUGE to SUM) with v5 from start', () => {
|
||||
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
|
||||
const newAttribute: BaseAutocompleteData = {
|
||||
key: 'new_sum_metric',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.SUM,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(newAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
metricName: 'new_sum_metric',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve aggregation operators when metric type remains the same (SUM to SUM)', () => {
|
||||
const sumMockQuery: IBuilderQuery = {
|
||||
...defaultMockQuery,
|
||||
aggregateAttribute: undefined,
|
||||
aggregateOperator: '',
|
||||
timeAggregation: undefined,
|
||||
spaceAggregation: undefined,
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
metricName: 'original_sum_metric',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useQueryOperations({
|
||||
query: sumMockQuery,
|
||||
index: 0,
|
||||
entityVersion: ENTITY_VERSION_V5,
|
||||
}),
|
||||
);
|
||||
|
||||
const newAttribute: BaseAutocompleteData = {
|
||||
key: 'new_sum_metric',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.SUM,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(newAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregateAttribute: newAttribute,
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
metricName: 'new_sum_metric',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,26 +73,6 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
SelectOption<string, string>[]
|
||||
>([]);
|
||||
|
||||
const [previousMetricInfo, setPreviousMetricInfo] = useState<{
|
||||
name: string;
|
||||
type: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (query) {
|
||||
const metricName =
|
||||
query.aggregateAttribute?.key ||
|
||||
(query.aggregations?.[0] as MetricAggregation)?.metricName;
|
||||
const metricType = query.aggregateAttribute?.type;
|
||||
if (metricName && metricType) {
|
||||
setPreviousMetricInfo({
|
||||
name: metricName,
|
||||
type: metricType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const { dataSource, aggregateOperator } = query;
|
||||
|
||||
const getNewListOfAdditionalFilters = useCallback(
|
||||
@@ -234,19 +214,12 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
);
|
||||
|
||||
const handleChangeAggregatorAttribute = useCallback(
|
||||
(
|
||||
value: BaseAutocompleteData,
|
||||
isEditMode?: boolean,
|
||||
attributeKeys?: BaseAutocompleteData[],
|
||||
): void => {
|
||||
(value: BaseAutocompleteData, isEditMode?: boolean): void => {
|
||||
const newQuery: IBuilderQuery = {
|
||||
...query,
|
||||
aggregateAttribute: value,
|
||||
};
|
||||
|
||||
const getAttributeKeyFromMetricName = (metricName: string): string =>
|
||||
attributeKeys?.find((key) => key.key === metricName)?.type || '';
|
||||
|
||||
if (
|
||||
newQuery.dataSource === DataSource.METRICS &&
|
||||
entityVersion === ENTITY_VERSION_V4
|
||||
@@ -294,97 +267,61 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
}
|
||||
|
||||
if (!isEditMode) {
|
||||
// Get current metric info
|
||||
const currentMetricName = newQuery.aggregateAttribute?.key || '';
|
||||
const currentMetricType = newQuery.aggregateAttribute?.type || '';
|
||||
if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.SUM) {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
},
|
||||
];
|
||||
} else if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.GAUGE) {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
},
|
||||
];
|
||||
} else {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: '',
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const prevMetricType = previousMetricInfo?.type
|
||||
? previousMetricInfo.type
|
||||
: getAttributeKeyFromMetricName(previousMetricInfo?.name || '');
|
||||
newQuery.aggregateOperator = '';
|
||||
newQuery.spaceAggregation = '';
|
||||
|
||||
// Check if metric type has changed by comparing with tracked previous values
|
||||
const metricTypeChanged =
|
||||
!prevMetricType || !currentMetricType
|
||||
? false
|
||||
: prevMetricType !== currentMetricType;
|
||||
|
||||
// Only reset operators if metric type has changed or if this is the first metric selection
|
||||
if (metricTypeChanged || !previousMetricInfo) {
|
||||
if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.SUM) {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
},
|
||||
];
|
||||
} else if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.GAUGE) {
|
||||
// Handled query with unknown metric to avoid 400 and 500 errors
|
||||
// With metric value typed and not available then - time - 'avg', space - 'avg'
|
||||
// If not typed - time - 'rate', space - 'sum', op - 'count'
|
||||
if (isEmpty(newQuery.aggregateAttribute?.type)) {
|
||||
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
spaceAggregation: MetricAggregateOperator.AVG,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: '',
|
||||
timeAggregation: MetricAggregateOperator.COUNT,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
newQuery.aggregateOperator = '';
|
||||
newQuery.spaceAggregation = '';
|
||||
|
||||
// Handled query with unknown metric to avoid 400 and 500 errors
|
||||
// With metric value typed and not available then - time - 'avg', space - 'avg'
|
||||
// If not typed - time - 'rate', space - 'sum', op - 'count'
|
||||
if (isEmpty(newQuery.aggregateAttribute?.type)) {
|
||||
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.AVG,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.COUNT,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If metric type hasn't changed, preserve existing aggregations but update metric name
|
||||
const currentAggregation = query.aggregations?.[0] as MetricAggregation;
|
||||
if (currentAggregation) {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
...currentAggregation,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Update the tracked metric info for next comparison only if we have valid data
|
||||
if (currentMetricName && currentMetricType) {
|
||||
setPreviousMetricInfo({
|
||||
name: currentMetricName,
|
||||
type: currentMetricType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,7 +334,6 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
handleSetQueryData,
|
||||
index,
|
||||
handleMetricAggregateAtributeTypes,
|
||||
previousMetricInfo,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
Location,
|
||||
NavigateFunction,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from 'react-router-dom-v5-compat';
|
||||
import { isEventObject } from 'utils/isEventObject';
|
||||
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
// state uses 'any' because react-router's NavigateOptions interface uses it
|
||||
interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: any;
|
||||
@@ -90,74 +83,6 @@ const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
|
||||
|
||||
return newKeys.length > 0;
|
||||
};
|
||||
|
||||
// Helper function to extract options from arguments
|
||||
const extractOptions = (
|
||||
optionsOrEvent?:
|
||||
| NavigateOptions
|
||||
| React.MouseEvent
|
||||
| MouseEvent
|
||||
| KeyboardEvent,
|
||||
options?: NavigateOptions,
|
||||
): NavigateOptions => {
|
||||
const isEvent = isEventObject(optionsOrEvent);
|
||||
const actualOptions = isEvent ? options : (optionsOrEvent as NavigateOptions);
|
||||
|
||||
const shouldOpenInNewTab =
|
||||
isEvent && (optionsOrEvent.metaKey || optionsOrEvent.ctrlKey);
|
||||
|
||||
return {
|
||||
...actualOptions,
|
||||
newTab: shouldOpenInNewTab || actualOptions?.newTab,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to create target URL
|
||||
const createTargetUrl = (
|
||||
to: string | SafeNavigateParams,
|
||||
location: Location,
|
||||
): URL => {
|
||||
if (typeof to === 'string') {
|
||||
return new URL(to, window.location.origin);
|
||||
}
|
||||
return new URL(
|
||||
`${to.pathname || location.pathname}${to.search || ''}`,
|
||||
window.location.origin,
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to handle new tab navigation
|
||||
const handleNewTabNavigation = (
|
||||
to: string | SafeNavigateParams,
|
||||
location: Location,
|
||||
): void => {
|
||||
const targetPath =
|
||||
typeof to === 'string'
|
||||
? to
|
||||
: `${to.pathname || location.pathname}${to.search || ''}`;
|
||||
window.open(targetPath, '_blank');
|
||||
};
|
||||
|
||||
// Helper function to perform navigation
|
||||
const performNavigation = (
|
||||
to: string | SafeNavigateParams,
|
||||
navigationOptions: NavigateOptions,
|
||||
navigate: NavigateFunction,
|
||||
location: Location,
|
||||
): void => {
|
||||
if (typeof to === 'string') {
|
||||
navigate(to, navigationOptions);
|
||||
} else {
|
||||
navigate(
|
||||
{
|
||||
pathname: to.pathname || location.pathname,
|
||||
search: to.search,
|
||||
},
|
||||
navigationOptions,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const useSafeNavigate = (
|
||||
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
|
||||
preventSameUrlNavigation: true,
|
||||
@@ -165,11 +90,6 @@ export const useSafeNavigate = (
|
||||
): {
|
||||
safeNavigate: (
|
||||
to: string | SafeNavigateParams,
|
||||
optionsOrEvent?:
|
||||
| NavigateOptions
|
||||
| React.MouseEvent
|
||||
| MouseEvent
|
||||
| KeyboardEvent,
|
||||
options?: NavigateOptions,
|
||||
) => void;
|
||||
} => {
|
||||
@@ -177,25 +97,30 @@ export const useSafeNavigate = (
|
||||
const location = useLocation();
|
||||
|
||||
const safeNavigate = useCallback(
|
||||
(
|
||||
to: string | SafeNavigateParams,
|
||||
optionsOrEvent?:
|
||||
| NavigateOptions
|
||||
| React.MouseEvent
|
||||
| MouseEvent
|
||||
| KeyboardEvent,
|
||||
options?: NavigateOptions,
|
||||
) => {
|
||||
const finalOptions = extractOptions(optionsOrEvent, options);
|
||||
(to: string | SafeNavigateParams, options?: NavigateOptions) => {
|
||||
const currentUrl = new URL(
|
||||
`${location.pathname}${location.search}`,
|
||||
window.location.origin,
|
||||
);
|
||||
const targetUrl = createTargetUrl(to, location);
|
||||
|
||||
// Handle new tab navigation
|
||||
if (finalOptions?.newTab) {
|
||||
handleNewTabNavigation(to, location);
|
||||
let targetUrl: URL;
|
||||
|
||||
if (typeof to === 'string') {
|
||||
targetUrl = new URL(to, window.location.origin);
|
||||
} else {
|
||||
targetUrl = new URL(
|
||||
`${to.pathname || location.pathname}${to.search || ''}`,
|
||||
window.location.origin,
|
||||
);
|
||||
}
|
||||
|
||||
// If newTab is true, open in new tab and return early
|
||||
if (options?.newTab) {
|
||||
const targetPath =
|
||||
typeof to === 'string'
|
||||
? to
|
||||
: `${to.pathname || location.pathname}${to.search || ''}`;
|
||||
window.open(targetPath, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,13 +132,23 @@ export const useSafeNavigate = (
|
||||
}
|
||||
|
||||
const navigationOptions = {
|
||||
...finalOptions,
|
||||
replace: isDefaultParamsNavigation || finalOptions?.replace,
|
||||
...options,
|
||||
replace: isDefaultParamsNavigation || options?.replace,
|
||||
};
|
||||
|
||||
performNavigation(to, navigationOptions, navigate, location);
|
||||
if (typeof to === 'string') {
|
||||
navigate(to, navigationOptions);
|
||||
} else {
|
||||
navigate(
|
||||
{
|
||||
pathname: to.pathname || location.pathname,
|
||||
search: to.search,
|
||||
},
|
||||
navigationOptions,
|
||||
);
|
||||
}
|
||||
},
|
||||
[navigate, location, preventSameUrlNavigation],
|
||||
[navigate, location.pathname, location.search, preventSameUrlNavigation],
|
||||
);
|
||||
|
||||
return { safeNavigate };
|
||||
|
||||
@@ -1,634 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { LocationDescriptorObject } from 'history';
|
||||
|
||||
import history from '../history';
|
||||
|
||||
jest.mock('history', () => {
|
||||
const actualHistory = jest.requireActual('history');
|
||||
const mockPush = jest.fn();
|
||||
const mockReplace = jest.fn();
|
||||
const mockGo = jest.fn();
|
||||
const mockGoBack = jest.fn();
|
||||
const mockGoForward = jest.fn();
|
||||
const mockBlock = jest.fn(() => jest.fn());
|
||||
const mockListen = jest.fn(() => jest.fn());
|
||||
const mockCreateHref = jest.fn((location) => {
|
||||
if (typeof location === 'string') return location;
|
||||
return actualHistory.createPath(location);
|
||||
});
|
||||
|
||||
const baseHistory = {
|
||||
length: 2,
|
||||
action: 'PUSH' as const,
|
||||
location: {
|
||||
pathname: '/current-path',
|
||||
search: '?existing=param',
|
||||
hash: '#section',
|
||||
state: { existing: 'state' },
|
||||
key: 'test-key',
|
||||
},
|
||||
push: mockPush,
|
||||
replace: mockReplace,
|
||||
go: mockGo,
|
||||
goBack: mockGoBack,
|
||||
goForward: mockGoForward,
|
||||
block: mockBlock,
|
||||
listen: mockListen,
|
||||
createHref: mockCreateHref,
|
||||
};
|
||||
|
||||
return {
|
||||
...actualHistory,
|
||||
createBrowserHistory: jest.fn(() => baseHistory),
|
||||
};
|
||||
});
|
||||
|
||||
interface TestUser {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface TestState {
|
||||
from?: string;
|
||||
user?: TestUser;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
describe('Enhanced History Methods', () => {
|
||||
let mockWindowOpen: jest.SpyInstance;
|
||||
let originalPush: jest.MockedFunction<typeof history.push>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockWindowOpen = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
|
||||
originalPush = history.originalPush as jest.MockedFunction<
|
||||
typeof history.push
|
||||
>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockWindowOpen.mockRestore();
|
||||
});
|
||||
|
||||
describe('history.push() - String Path Navigation', () => {
|
||||
it('should handle simple string path navigation', () => {
|
||||
history.push('/dashboard');
|
||||
|
||||
expect(originalPush).toHaveBeenCalledTimes(1);
|
||||
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle string path with state', () => {
|
||||
const testState: TestState = { from: 'home', timestamp: Date.now() };
|
||||
|
||||
history.push('/dashboard', testState);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith('/dashboard', testState);
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle string path with query parameters', () => {
|
||||
history.push('/logs?filter=error&timeRange=24h');
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(
|
||||
'/logs?filter=error&timeRange=24h',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle string path with hash', () => {
|
||||
history.push('/docs#installation');
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith('/docs#installation', undefined);
|
||||
});
|
||||
|
||||
it('should handle complex URL with all components', () => {
|
||||
const complexUrl = '/api/traces?service=backend&status=error#span-details';
|
||||
const state: TestState = {
|
||||
user: { id: 1, name: 'John', email: 'john@test.com' },
|
||||
};
|
||||
|
||||
history.push(complexUrl, state);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(complexUrl, state);
|
||||
});
|
||||
});
|
||||
|
||||
describe('history.push() - Location Object Navigation', () => {
|
||||
it('should handle location object with only pathname', () => {
|
||||
const location: LocationDescriptorObject = {
|
||||
pathname: '/metrics',
|
||||
};
|
||||
|
||||
history.push(location);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle location object with pathname and search', () => {
|
||||
const location: LocationDescriptorObject = {
|
||||
pathname: '/logs',
|
||||
search: '?filter=error&severity=high',
|
||||
};
|
||||
|
||||
history.push(location);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
||||
});
|
||||
|
||||
it('should handle location object with all properties', () => {
|
||||
const location: LocationDescriptorObject<TestState> = {
|
||||
pathname: '/traces',
|
||||
search: '?service=api-server&duration=slow',
|
||||
hash: '#span-123',
|
||||
state: { from: 'dashboard', timestamp: Date.now() },
|
||||
key: 'unique-key',
|
||||
};
|
||||
|
||||
history.push(location);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
||||
});
|
||||
|
||||
it('should handle location object with state passed separately', () => {
|
||||
const location: LocationDescriptorObject = {
|
||||
pathname: '/alerts',
|
||||
search: '?type=critical',
|
||||
};
|
||||
const separateState: TestState = { from: 'monitoring' };
|
||||
|
||||
history.push(location, separateState);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(location, separateState);
|
||||
});
|
||||
|
||||
it('should handle empty location object', () => {
|
||||
const location: LocationDescriptorObject = {};
|
||||
|
||||
history.push(location);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
||||
});
|
||||
|
||||
it('should preserve current pathname when updating search', () => {
|
||||
const location: LocationDescriptorObject = {
|
||||
pathname: history.location.pathname,
|
||||
search: '?newParam=value',
|
||||
};
|
||||
|
||||
history.push(location);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
||||
expect(originalPush.mock.calls[0][0]).toHaveProperty(
|
||||
'pathname',
|
||||
'/current-path',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('history.push() - Event Handling (Cmd/Ctrl+Click)', () => {
|
||||
describe('MouseEvent handling', () => {
|
||||
it('should open in new tab when metaKey is pressed with string path', () => {
|
||||
const event = new MouseEvent('click', { metaKey: true });
|
||||
|
||||
history.push('/dashboard', event);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
|
||||
expect(originalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open in new tab when ctrlKey is pressed with string path', () => {
|
||||
const event = new MouseEvent('click', { ctrlKey: true });
|
||||
|
||||
history.push('/metrics', event);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith('/metrics', '_blank');
|
||||
expect(originalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open in new tab when both metaKey and ctrlKey are pressed', () => {
|
||||
const event = new MouseEvent('click', { metaKey: true, ctrlKey: true });
|
||||
|
||||
history.push('/logs', event);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
|
||||
expect(originalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle normal click without meta/ctrl keys', () => {
|
||||
const event = new MouseEvent('click', { metaKey: false, ctrlKey: false });
|
||||
const state: TestState = { from: 'nav' };
|
||||
|
||||
history.push('/alerts', event, state);
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
||||
expect(originalPush).toHaveBeenCalledWith('/alerts', state);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KeyboardEvent handling', () => {
|
||||
it('should open in new tab when metaKey is pressed with keyboard event', () => {
|
||||
const event = new KeyboardEvent('keydown', { metaKey: true });
|
||||
|
||||
history.push('/traces', event);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith('/traces', '_blank');
|
||||
expect(originalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open in new tab when ctrlKey is pressed with keyboard event', () => {
|
||||
const event = new KeyboardEvent('keydown', { ctrlKey: true });
|
||||
|
||||
history.push('/pipelines', event);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith('/pipelines', '_blank');
|
||||
expect(originalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('React SyntheticEvent handling', () => {
|
||||
it('should handle React MouseEvent with metaKey', () => {
|
||||
const nativeEvent = new MouseEvent('click', { metaKey: true });
|
||||
const reactEvent = {
|
||||
nativeEvent,
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
} as React.MouseEvent;
|
||||
|
||||
history.push('/dashboard', reactEvent);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
|
||||
expect(originalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle React MouseEvent with ctrlKey', () => {
|
||||
const nativeEvent = new MouseEvent('click', { ctrlKey: true });
|
||||
const reactEvent = {
|
||||
nativeEvent,
|
||||
metaKey: false,
|
||||
ctrlKey: true,
|
||||
} as React.MouseEvent;
|
||||
|
||||
history.push('/logs', reactEvent);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
|
||||
expect(originalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle React MouseEvent without modifier keys', () => {
|
||||
const nativeEvent = new MouseEvent('click');
|
||||
const reactEvent = {
|
||||
nativeEvent,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
} as React.MouseEvent;
|
||||
|
||||
history.push('/metrics', reactEvent);
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
||||
expect(originalPush).toHaveBeenCalledWith('/metrics', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Location Object with Event handling', () => {
|
||||
it('should open location object URL in new tab with metaKey', () => {
|
||||
const location: LocationDescriptorObject = {
|
||||
pathname: '/traces',
|
||||
search: '?service=backend',
|
||||
hash: '#span-details',
|
||||
};
|
||||
const event = new MouseEvent('click', { metaKey: true });
|
||||
|
||||
history.push(location, event);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'/traces?service=backend#span-details',
|
||||
'_blank',
|
||||
);
|
||||
expect(originalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open location object URL in new tab with ctrlKey', () => {
|
||||
const location: LocationDescriptorObject = {
|
||||
pathname: '/alerts',
|
||||
search: '?status=firing',
|
||||
};
|
||||
const event = new MouseEvent('click', { ctrlKey: true });
|
||||
|
||||
history.push(location, event);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'/alerts?status=firing',
|
||||
'_blank',
|
||||
);
|
||||
expect(originalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle location object with normal navigation', () => {
|
||||
const location: LocationDescriptorObject = {
|
||||
pathname: '/dashboard',
|
||||
search: '?tab=overview',
|
||||
};
|
||||
const event = new MouseEvent('click', { metaKey: false, ctrlKey: false });
|
||||
const state: TestState = { from: 'home' };
|
||||
|
||||
history.push(location, event, state);
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
||||
expect(originalPush).toHaveBeenCalledWith(location, state);
|
||||
});
|
||||
|
||||
it('should handle complex location object with all properties in new tab', () => {
|
||||
const location: LocationDescriptorObject<TestState> = {
|
||||
pathname: '/api/v1/traces',
|
||||
search: '?limit=100&offset=0&service=auth',
|
||||
hash: '#result-section',
|
||||
state: { from: 'explorer' }, // State is ignored in new tab
|
||||
};
|
||||
const event = new MouseEvent('click', { metaKey: true });
|
||||
|
||||
history.push(location, event);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'/api/v1/traces?limit=100&offset=0&service=auth#result-section',
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('history.push() - Edge Cases and Error Scenarios', () => {
|
||||
it('should handle undefined as second parameter', () => {
|
||||
history.push('/dashboard', undefined);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
|
||||
});
|
||||
|
||||
it('should handle null as second parameter', () => {
|
||||
history.push('/logs', null);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith('/logs', null);
|
||||
});
|
||||
|
||||
it('should handle empty string path', () => {
|
||||
history.push('');
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith('', undefined);
|
||||
});
|
||||
|
||||
it('should handle root path', () => {
|
||||
history.push('/');
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith('/', undefined);
|
||||
});
|
||||
|
||||
it('should handle relative paths', () => {
|
||||
history.push('../parent');
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith('../parent', undefined);
|
||||
});
|
||||
|
||||
it('should handle special characters in path', () => {
|
||||
const specialPath = '/path/with spaces/and#special?chars=@$%';
|
||||
|
||||
history.push(specialPath);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(specialPath, undefined);
|
||||
});
|
||||
|
||||
it('should handle location object with undefined values', () => {
|
||||
const location: LocationDescriptorObject = {
|
||||
pathname: undefined,
|
||||
search: undefined,
|
||||
hash: undefined,
|
||||
state: undefined,
|
||||
};
|
||||
|
||||
history.push(location);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(location, undefined);
|
||||
});
|
||||
|
||||
it('should handle very long URLs', () => {
|
||||
const longParam = 'x'.repeat(1000);
|
||||
const longUrl = `/path?param=${longParam}`;
|
||||
|
||||
history.push(longUrl);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(longUrl, undefined);
|
||||
});
|
||||
|
||||
it('should handle object that looks like an event but isnt', () => {
|
||||
const fakeEvent = {
|
||||
metaKey: 'not-a-boolean', // Invalid type but still truthy values
|
||||
ctrlKey: 'not-a-boolean',
|
||||
};
|
||||
|
||||
history.push('/dashboard', fakeEvent as any);
|
||||
|
||||
// The implementation checks if metaKey/ctrlKey exist and are truthy values
|
||||
// Since these are truthy strings, it will be treated as an event
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
|
||||
expect(originalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle event-like object with falsy values', () => {
|
||||
const fakeEventFalsy = {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
};
|
||||
|
||||
history.push('/dashboard', fakeEventFalsy as any);
|
||||
|
||||
// The object is detected as an event (has metaKey/ctrlKey properties)
|
||||
// but since both are false, it doesn't open in new tab
|
||||
// When treated as event, third param (state) is undefined
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
||||
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
|
||||
});
|
||||
|
||||
it('should handle partial event-like objects', () => {
|
||||
const partialEvent = { metaKey: true }; // Has metaKey but not instanceof MouseEvent
|
||||
|
||||
history.push('/logs', partialEvent as any);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
|
||||
expect(originalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle object without event properties as state', () => {
|
||||
const regularObject = {
|
||||
someData: 'value',
|
||||
anotherProp: 123,
|
||||
// No metaKey or ctrlKey properties
|
||||
};
|
||||
|
||||
history.push('/page', regularObject);
|
||||
|
||||
// Object without metaKey/ctrlKey is treated as state, not event
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
||||
expect(originalPush).toHaveBeenCalledWith('/page', regularObject);
|
||||
});
|
||||
});
|
||||
|
||||
describe('history.push() - State Handling', () => {
|
||||
it('should pass state with string path', () => {
|
||||
const complexState: TestState = {
|
||||
from: 'dashboard',
|
||||
user: { id: 123, name: 'Test User', email: 'test@example.com' },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
history.push('/profile', complexState);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith('/profile', complexState);
|
||||
});
|
||||
|
||||
it('should handle state with location object', () => {
|
||||
const location: LocationDescriptorObject<TestState> = {
|
||||
pathname: '/settings',
|
||||
state: { from: 'profile' },
|
||||
};
|
||||
const additionalState: TestState = { timestamp: Date.now() };
|
||||
|
||||
history.push(location, additionalState);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(location, additionalState);
|
||||
});
|
||||
|
||||
it('should handle state with event and string path', () => {
|
||||
const event = new MouseEvent('click', { metaKey: false });
|
||||
const state: TestState = { from: 'nav' };
|
||||
|
||||
history.push('/dashboard', event, state);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith('/dashboard', state);
|
||||
});
|
||||
|
||||
it('should handle state with event and location object', () => {
|
||||
const location: LocationDescriptorObject = {
|
||||
pathname: '/logs',
|
||||
};
|
||||
const event = new MouseEvent('click', { metaKey: false });
|
||||
const state: TestState = { from: 'sidebar' };
|
||||
|
||||
history.push(location, event, state);
|
||||
|
||||
expect(originalPush).toHaveBeenCalledWith(location, state);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other History Methods', () => {
|
||||
it('should have working replace method', () => {
|
||||
// replace should exist and be callable
|
||||
expect(history.replace).toBeDefined();
|
||||
expect(typeof history.replace).toBe('function');
|
||||
|
||||
history.replace('/new-path');
|
||||
|
||||
const mockReplace = (history as any).replace as jest.MockedFunction<
|
||||
typeof history.replace
|
||||
>;
|
||||
expect(mockReplace).toHaveBeenCalledWith('/new-path');
|
||||
});
|
||||
|
||||
it('should have working go method', () => {
|
||||
expect(history.go).toBeDefined();
|
||||
expect(typeof history.go).toBe('function');
|
||||
|
||||
history.go(-2);
|
||||
|
||||
const mockGo = (history as any).go as jest.MockedFunction<typeof history.go>;
|
||||
expect(mockGo).toHaveBeenCalledWith(-2);
|
||||
});
|
||||
|
||||
it('should have working goBack method', () => {
|
||||
expect(history.goBack).toBeDefined();
|
||||
expect(typeof history.goBack).toBe('function');
|
||||
|
||||
history.goBack();
|
||||
|
||||
const mockGoBack = (history as any).goBack as jest.MockedFunction<
|
||||
typeof history.goBack
|
||||
>;
|
||||
expect(mockGoBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have working goForward method', () => {
|
||||
expect(history.goForward).toBeDefined();
|
||||
expect(typeof history.goForward).toBe('function');
|
||||
|
||||
history.goForward();
|
||||
|
||||
const mockGoForward = (history as any).goForward as jest.MockedFunction<
|
||||
typeof history.goForward
|
||||
>;
|
||||
expect(mockGoForward).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have working block method', () => {
|
||||
expect(history.block).toBeDefined();
|
||||
expect(typeof history.block).toBe('function');
|
||||
|
||||
const unblock = history.block('Are you sure?');
|
||||
|
||||
expect(typeof unblock).toBe('function');
|
||||
const mockBlock = (history as any).block as jest.MockedFunction<
|
||||
typeof history.block
|
||||
>;
|
||||
expect(mockBlock).toHaveBeenCalledWith('Are you sure?');
|
||||
});
|
||||
|
||||
it('should have working listen method', () => {
|
||||
expect(history.listen).toBeDefined();
|
||||
expect(typeof history.listen).toBe('function');
|
||||
|
||||
const listener = jest.fn();
|
||||
|
||||
const unlisten = history.listen(listener);
|
||||
|
||||
expect(typeof unlisten).toBe('function');
|
||||
const mockListen = (history as any).listen as jest.MockedFunction<
|
||||
typeof history.listen
|
||||
>;
|
||||
expect(mockListen).toHaveBeenCalledWith(listener);
|
||||
});
|
||||
|
||||
it('should have working createHref method', () => {
|
||||
expect(history.createHref).toBeDefined();
|
||||
expect(typeof history.createHref).toBe('function');
|
||||
|
||||
const location: LocationDescriptorObject = {
|
||||
pathname: '/test',
|
||||
search: '?query=value',
|
||||
};
|
||||
|
||||
const href = history.createHref(location);
|
||||
|
||||
expect(href).toBe('/test?query=value');
|
||||
});
|
||||
|
||||
it('should have accessible location property', () => {
|
||||
expect(history.location).toBeDefined();
|
||||
expect(history.location.pathname).toBe('/current-path');
|
||||
expect(history.location.search).toBe('?existing=param');
|
||||
expect(history.location.hash).toBe('#section');
|
||||
expect(history.location.state).toEqual({ existing: 'state' });
|
||||
});
|
||||
|
||||
it('should have accessible length property', () => {
|
||||
expect(history.length).toBeDefined();
|
||||
expect(history.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should have accessible action property', () => {
|
||||
expect(history.action).toBeDefined();
|
||||
expect(history.action).toBe('PUSH');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import { convertNewDataToOld } from 'lib/newQueryBuilder/convertNewDataToOld';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { SuccessResponse, SuccessResponseV2, Warning } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { prepareQueryRangePayload } from './prepareQueryRangePayload';
|
||||
@@ -76,13 +76,14 @@ const getQueryDataSource = (
|
||||
|
||||
const getLegendForSingleAggregation = (
|
||||
queryData: QueryData,
|
||||
allQueries: IBuilderQuery[],
|
||||
payloadQuery: Query,
|
||||
aggregationAlias: string,
|
||||
aggregationExpression: string,
|
||||
labelName: string,
|
||||
singleAggregation: boolean,
|
||||
) => {
|
||||
const queryItem = allQueries.find(
|
||||
// Find the corresponding query in payloadQuery
|
||||
const queryItem = payloadQuery.builder?.queryData.find(
|
||||
(query) => query.queryName === queryData.queryName,
|
||||
);
|
||||
|
||||
@@ -107,13 +108,14 @@ const getLegendForSingleAggregation = (
|
||||
|
||||
const getLegendForMultipleAggregations = (
|
||||
queryData: QueryData,
|
||||
allQueries: IBuilderQuery[],
|
||||
payloadQuery: Query,
|
||||
aggregationAlias: string,
|
||||
aggregationExpression: string,
|
||||
labelName: string,
|
||||
singleAggregation: boolean,
|
||||
) => {
|
||||
const queryItem = allQueries.find(
|
||||
// Find the corresponding query in payloadQuery
|
||||
const queryItem = payloadQuery.builder?.queryData.find(
|
||||
(query) => query.queryName === queryData.queryName,
|
||||
);
|
||||
|
||||
@@ -146,18 +148,15 @@ export const getLegend = (
|
||||
return labelName;
|
||||
}
|
||||
|
||||
// Combine queryData and queryTraceOperator
|
||||
const allQueries = [
|
||||
...(payloadQuery?.builder?.queryData || []),
|
||||
...(payloadQuery?.builder?.queryTraceOperator || []),
|
||||
];
|
||||
|
||||
const aggregationPerQuery = allQueries.reduce((acc, query) => {
|
||||
if (query.queryName === queryData.queryName) {
|
||||
acc[query.queryName] = createAggregation(query);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const aggregationPerQuery = payloadQuery?.builder?.queryData.reduce(
|
||||
(acc, query) => {
|
||||
if (query.queryName === queryData.queryName) {
|
||||
acc[query.queryName] = createAggregation(query);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const metaData = queryData?.metaData;
|
||||
const aggregation =
|
||||
@@ -166,8 +165,8 @@ export const getLegend = (
|
||||
const aggregationAlias = aggregation?.alias || '';
|
||||
const aggregationExpression = aggregation?.expression || '';
|
||||
|
||||
// Check if there's only one total query
|
||||
const singleQuery = allQueries.length === 1;
|
||||
// Check if there's only one total query (queryData)
|
||||
const singleQuery = payloadQuery?.builder?.queryData?.length === 1;
|
||||
const singleAggregation =
|
||||
aggregationPerQuery?.[metaData?.queryName]?.length === 1;
|
||||
|
||||
@@ -175,7 +174,7 @@ export const getLegend = (
|
||||
return singleQuery
|
||||
? getLegendForSingleAggregation(
|
||||
queryData,
|
||||
allQueries,
|
||||
payloadQuery,
|
||||
aggregationAlias,
|
||||
aggregationExpression,
|
||||
labelName,
|
||||
@@ -183,7 +182,7 @@ export const getLegend = (
|
||||
)
|
||||
: getLegendForMultipleAggregations(
|
||||
queryData,
|
||||
allQueries,
|
||||
payloadQuery,
|
||||
aggregationAlias,
|
||||
aggregationExpression,
|
||||
labelName,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user