Compare commits

...

17 Commits

Author SHA1 Message Date
primus-bot[bot]
4bbe8c0ee7 chore(release): bump to v0.84.0 (#7995)
#### Summary
 - Release SigNoz v0.84.0
2025-05-21 12:18:14 +05:30
Nityananda Gohain
0f7d226b9b Fix: exists clause in logs QB (#7987)
* fix: exists in logs QB

* fix: exists in logs json qb
2025-05-21 10:22:42 +05:30
Shaheer Kochai
e03342e001 feat: add support for request integrations in aws integrations page (#7968)
* feat: add support for request integrations in aws integrations page

* chore: write test for request aws integrations
2025-05-20 21:39:40 +05:30
Shaheer Kochai
57f96574ff fix: trace funnel bugfixes and improvements (#7922)
* fix: display the inter-step latency type in step metrics table

* chore: send latency type with n+1th step + make latency type optional

* fix: fetch and format get funnel steps overview metrics

* chore: remove dev env check

* fix: overall fixes

* fix: don't cache validate query + trigger validate on changing error and where clause as well

* fix: display the latency type in step overview metrics table + p99_latency to latency

* chore: revert dev env check removal (remove after BE changes are merged)

* fix: adjust create API response

* chore: useLocalStorage custom hook

* feat: improve the run funnel flow

- for the initial fetch of funnel results, require the user to run the funnel
- subsequently change the run funnel button to a refresh button
- display loading state while any of the funnel results APIs are being fetched

* fix: fix the issue of add step details breaking

* fix: refetch funnel details on rename success

* fix: redirect 'learn more' to trace funnels docs

* fix: handle potential undefined step in latency type calculation

* fix: properly handle incomplete steps state

* fix: fix the edge case of stale validation state on transitioning from invalid steps to valid steps

* fix: remove the side effect from render and move to useEffect
2025-05-20 19:44:05 +04:30
Yunus M
354e4b4b8f feat: show pricing update banner in home page (#7990)
* feat: show pricing update banner in  home page
2025-05-20 19:40:01 +05:30
Shaheer Kochai
d7102f69a9 feat: add support for S3 region buckets syncing (#7874)
* feat: add support for S3 region buckets syncing

* fix: hide the dropdown icon and not found dropdown for s3 buckets selector

* fix: display s3 buckets selector only for s3 sync service

* chore: tests for configure service s3 sync

---------

Co-authored-by: Piyush Singariya <piyushsingariya@gmail.com>
2025-05-20 13:32:32 +00:00
Aditya Singh
040c45b144 Custom Quick Filters: Logs (#7986)
* chore: quick filters - added filters init (#7867)

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>

* Custom quick filter: Other Filters | Search integration (#7939)

* chore: added filters init

* chore: handle save and discard

* chore: search and api intergrations

* feat: search on filters

* feat: style fix

* feat: style fix

* feat: signal to data source config

* feat: search styles

* feat: update drawer slide style

* feat: no results state

* fix: minor fix

* Custom Quick FIlters: UI fixes and Announcement Tooltip (#7950)

* feat: qf setting ui

* feat: add skeleton to dynamic qf

* fix: minor fix

* feat: announcement tooltip added

* feat: announcement tooltip added refactor

* feat: announcement tooltip styles

* feat: announcement tooltip integration

* fix: number vals in filter list

* feat: announcement tooltip show logic added

* feat: light mode styles

* feat: remove unwanted styles

* feat: remove filter disable when one filter added

* style: minor style

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>

* Custom quick filters: Tests and pr review comments (#7967)

* chore: added filters init

* chore: handle save and discard

* chore: search and api intergrations

* feat: search on filters

* feat: style fix

* feat: style fix

* feat: signal to data source config

* feat: search styles

* feat: update drawer slide style

* feat: no results state

* fix: minor fix

* feat: qf setting ui

* feat: add skeleton to dynamic qf

* fix: minor fix

* feat: announcement tooltip added

* feat: announcement tooltip added refactor

* feat: announcement tooltip styles

* feat: announcement tooltip integration

* fix: number vals in filter list

* feat: announcement tooltip show logic added

* feat: light mode styles

* feat: remove unwanted styles

* feat: remove filter disable when one filter added

* style: minor style

* fix: minor refactor

* test: added test cases

* feat: integrate custom quick filters in logs

* feat: code refactor

* feat: debounce search

* feat: refactor

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>
2025-05-20 12:39:49 +00:00
Sahil Khan
207d7602ab chore: changed name of api monitoring from third party apis to external apis (#7989) 2025-05-20 17:24:09 +05:30
Srikanth Chekuri
018346ca18 chore: add aggregation expr rewriter and exhaustive tests for logs filter (#7972) 2025-05-20 16:54:34 +05:30
Ekansh Gupta
7290ab3602 feat: added entry point operations api for the service overview page (#7957)
* feat: added entry point operations api for the service overview page

* feat: added entry point operations api for the service overview page

* feat: added entry point operations api for the service overview page

* feat: added entry point operations api for the service overview page

* feat: added entry point operations api for the service overview page

* feat: added entry point operations api for the service overview page

* feat: added entry point operations api for the service overview page

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-05-20 05:58:40 +00:00
Shaheer Kochai
88239cec4d fix: AWS integration bugfixes (#7886)
* fix(AccountSettingsModal): add region deselect functionality to region selector

* fix(AWS integration): redirect help button to aws integration documentation

* style(Header): update button color on hover for improved visibility
2025-05-20 03:48:11 +00:00
SagarRajput-7
10ba0e6b4f feat: added error preview and warning text with info on cyclic dependency detected (#7893)
* feat: added error preview and warning text with info on cyclic dependency detected

* feat: removed console.log

* feat: restricted save when cycle found

* feat: added test cases for variableitem flow and update test cases

* feat: updated test cases

* feat: corrected the recent resolved title text
2025-05-19 17:28:26 +07:00
Shaheer Kochai
88e1e42bf0 chore: add analytics events for trace funnels (#7638) 2025-05-19 09:22:35 +00:00
Piyush Singariya
a0d896557e feat: introducing ECS + SQS integration (#7840)
* feat: introducing S3 sync as AWS integration

* chore: trying restructuring

* chore: in progress

* chore: restructuring looks ok

* chore: minor fix in tests

* feat: integration with Agent check-in complete

* chore: minor change in validation

* fix: removing validation and altering overview

* fix: aftermath of merge conflicts

* test: updating agent version

* test: updating agent version

* test: updating agent version 3

* test: updating agent version 11

* test: updating agent version 14

* chore: replace with newer error utility

* feat: introducing ECS integration (AWS Integrations)

* chore: adding metrics to ecs integration

* feat: adding base SQS files

* feat: adding metrics for SQS

* feat: adding ECS dashboard

* feat: adding dashboards for SQS

* fix: adding SentMessageSize metrics in SQS

* fix: for calculating log connection status for S3 Sync

* fix: adding check for svc type, fixing cw logs integration.json S3 Sync

* fix: in compiledCollectionStrat for servicetype s3sync

* test: testing agent version

* fix: change in data collected for S3 Sync logs

* test: testing agent 19

* chore: replace fmt.Errorf

* fix: tests and adding validation in S3 buckets

* fix: test TestAvailableServices

* chore: replacing fmt.Errorf

* chore: updating the agent version to latest

* chore: reverting some changes

* fix: remove services from Variables

* chore: change overview.png
2025-05-19 14:17:52 +05:30
Amlan Kumar Nandy
2b28c5f2e2 chore: persist the filters and time selection, modal open state in summary view (#7942) 2025-05-19 11:54:05 +07:00
Vibhu Pandey
6dbcc5fb9d fix(analytics): fix heartbeat event (#7975) 2025-05-19 08:04:33 +05:30
Vikrant Gupta
175e9a4c5e fix(apm): update the apdex to latest response structure (#7966)
* fix(apm): update the apdex to latest response structure

* fix(apm): update the apdex to latest response structure
2025-05-17 16:23:11 +05:30
147 changed files with 12392 additions and 1371 deletions

View File

@@ -174,7 +174,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.83.0
image: signoz/signoz:v0.84.0
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -110,7 +110,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.83.0
image: signoz/signoz:v0.84.0
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -177,7 +177,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.83.0}
image: signoz/signoz:${VERSION:-v0.84.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -110,7 +110,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.83.0}
image: signoz/signoz:${VERSION:-v0.84.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -31,6 +31,7 @@
"@dnd-kit/core": "6.1.0",
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
"@dnd-kit/utilities": "3.2.2",
"@grafana/data": "^11.2.3",
"@mdx-js/loader": "2.3.0",
"@mdx-js/react": "2.3.0",

View File

@@ -69,5 +69,5 @@
"METRICS_EXPLORER": "SigNoz | Metrics Explorer",
"METRICS_EXPLORER_EXPLORER": "SigNoz | Metrics Explorer",
"METRICS_EXPLORER_VIEWS": "SigNoz | Metrics Explorer",
"API_MONITORING": "SigNoz | Third Party API"
"API_MONITORING": "SigNoz | External APIs"
}

View File

@@ -1,8 +0,0 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { ApDexPayloadAndSettingsProps } from 'types/api/metrics/getApDex';
export const getApDexSettings = (
servicename: string,
): Promise<AxiosResponse<ApDexPayloadAndSettingsProps[]>> =>
axios.get(`/settings/apdex?services=${servicename}`);

View File

@@ -0,0 +1,25 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
const getCustomFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const { signal } = props;
try {
const response = await ApiBaseInstance.get(`orgs/me/filters/${signal}`);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getCustomFilters;

View File

@@ -0,0 +1,13 @@
import { ApiBaseInstance } from 'api';
import { AxiosError } from 'axios';
import { SuccessResponse } from 'types/api';
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
const updateCustomFiltersAPI = async (
props: UpdateCustomFiltersProps,
): Promise<SuccessResponse<void> | AxiosError> =>
ApiBaseInstance.put(`orgs/me/filters`, {
...props.data,
});
export default updateCustomFiltersAPI;

View File

@@ -22,7 +22,7 @@ export const createFunnel = async (
statusCode: 200,
error: null,
message: 'Funnel created successfully',
payload: response.data,
payload: response.data.data,
};
};
@@ -196,7 +196,9 @@ export interface FunnelOverviewResponse {
avg_rate: number;
conversion_rate: number | null;
errors: number;
// TODO(shaheer): remove p99_latency once we have support for latency
p99_latency: number;
latency: number;
};
}>;
}
@@ -222,13 +224,6 @@ export const getFunnelOverview = async (
};
};
export interface SlowTracesPayload {
start_time: number;
end_time: number;
step_a_order: number;
step_b_order: number;
}
export interface SlowTraceData {
status: string;
data: Array<{
@@ -243,7 +238,7 @@ export interface SlowTraceData {
export const getFunnelSlowTraces = async (
funnelId: string,
payload: SlowTracesPayload,
payload: FunnelOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<SlowTraceData> | ErrorResponse> => {
const response = await axios.post(
@@ -261,12 +256,6 @@ export const getFunnelSlowTraces = async (
payload: response.data,
};
};
export interface ErrorTracesPayload {
start_time: number;
end_time: number;
step_a_order: number;
step_b_order: number;
}
export interface ErrorTraceData {
status: string;
@@ -282,7 +271,7 @@ export interface ErrorTraceData {
export const getFunnelErrorTraces = async (
funnelId: string,
payload: ErrorTracesPayload,
payload: FunnelOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<ErrorTraceData> | ErrorResponse> => {
const response: AxiosResponse = await axios.post(
@@ -337,3 +326,37 @@ export const getFunnelSteps = async (
payload: response.data,
};
};
export interface FunnelStepsOverviewPayload {
start_time: number;
end_time: number;
step_start?: number;
step_end?: number;
}
export interface FunnelStepsOverviewResponse {
status: string;
data: Array<{
timestamp: string;
data: Record<string, number>;
}>;
}
export const getFunnelStepsOverview = async (
funnelId: string,
payload: FunnelStepsOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelStepsOverviewResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps/overview`,
payload,
{ signal },
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};

View File

@@ -12,7 +12,7 @@ export const Logout = (): void => {
deleteLocalStorageKey(LOCALSTORAGE.LOGGED_IN_USER_NAME);
deleteLocalStorageKey(LOCALSTORAGE.CHAT_SUPPORT);
deleteLocalStorageKey(LOCALSTORAGE.USER_ID);
deleteLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT);
window.dispatchEvent(new CustomEvent('LOGOUT'));
// eslint-disable-next-line @typescript-eslint/ban-ts-comment

View File

@@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
ApDexPayloadAndSettingsProps,
PayloadProps,
} from 'types/api/metrics/getApDex';
const getApDexSettings = async (
servicename: string,
): Promise<SuccessResponseV2<ApDexPayloadAndSettingsProps[]>> => {
try {
const response = await axios.get<PayloadProps>(
`/settings/apdex?services=${servicename}`,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getApDexSettings;

View File

@@ -19,7 +19,7 @@ import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSea
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { cloneDeep, isArray, isEmpty, isEqual, isFunction } from 'lodash-es';
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useMemo, useState } from 'react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -82,7 +82,9 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const attributeValues: string[] = useMemo(() => {
const dataType = filter.attributeKey.dataType || DataTypes.String;
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
return (data?.payload?.[key] || []).filter((val) => !isEmpty(val));
return (data?.payload?.[key] || []).filter(
(val) => val !== undefined && val !== null,
);
}, [data?.payload, filter.attributeKey.dataType]);
const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount);

View File

@@ -1,7 +1,16 @@
.quick-filters-container {
display: flex;
height: 100%;
.quick-filters-settings-container {
position: relative;
}
}
.quick-filters {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
border-right: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
@@ -44,7 +53,7 @@
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
line-height: 18px;
text-transform: uppercase;
}
}
@@ -52,7 +61,7 @@
.right-actions {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
width: 100%;
justify-content: flex-end;
@@ -63,10 +72,34 @@
}
.sync-icon {
background-color: var(--bg-ink-500);
border: 0;
box-shadow: none;
}
.right-action-icon-container {
position: relative;
display: flex;
padding: 2px;
background-color: var(--bg-ink-500);
.settings-icon {
height: 14px;
width: 14px;
cursor: pointer;
}
&.active,
&:hover {
background: var(--bg-slate-500);
}
}
}
}
.quick-filters-skeleton {
.ant-skeleton-input {
width: 236px;
margin: 8px 12px;
}
}
}
@@ -90,8 +123,12 @@
}
}
.right-actions {
.sync-icon {
.right-action-icon-container {
background-color: var(--bg-vanilla-100);
&.active,
&:hover {
background-color: var(--bg-vanilla-200);
}
}
}
}

View File

@@ -5,18 +5,43 @@ import {
SyncOutlined,
VerticalAlignTopOutlined,
} from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
import { Skeleton, Tooltip, Typography } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import classNames from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep, isFunction } from 'lodash-es';
import { cloneDeep, isFunction, isNull } from 'lodash-es';
import { Settings2 as SettingsIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import Slider from './FilterRenderers/Slider/Slider';
import useFilterConfig from './hooks/useFilterConfig';
import AnnouncementTooltip from './QuickFiltersSettings/AnnouncementTooltip';
import QuickFiltersSettings from './QuickFiltersSettings/QuickFiltersSettings';
import { FiltersType, IQuickFiltersProps, QuickFiltersSource } from './types';
export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const { config, handleFilterVisibilityChange, source, onFilterChange } = props;
const {
className,
config,
handleFilterVisibilityChange,
source,
onFilterChange,
signal,
} = props;
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const {
filterConfig,
isDynamicFilters,
customFilters,
setIsStale,
isCustomFiltersLoading,
} = useFilterConfig({ signal, config });
const {
currentQuery,
@@ -24,6 +49,16 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
redirectWithQueryBuilderData,
} = useQueryBuilder();
const showAnnouncementTooltip = useMemo(() => {
const localStorageValue = getLocalStorageKey(
LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT,
);
if (!isNull(localStorageValue)) {
return !(localStorageValue === 'false');
}
return true;
}, []);
// clear all the filters for the query which is in sync with filters
const handleReset = (): void => {
const updatedQuery = cloneDeep(
@@ -63,68 +98,141 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
return (
<div className="quick-filters">
{source !== QuickFiltersSource.INFRA_MONITORING &&
source !== QuickFiltersSource.API_MONITORING && (
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">
{lastQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
{lastQueryName && (
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
<div className="quick-filters-container">
<div className="quick-filters">
{source !== QuickFiltersSource.INFRA_MONITORING &&
source !== QuickFiltersSource.API_MONITORING && (
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">
{lastQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
{lastQueryName && (
<Tooltip
title={`Filter currently in sync with query ${lastQueryName}`}
>
<Typography.Text className="sync-tag">
{lastQueryName}
</Typography.Text>
</Tooltip>
)}
</section>
<section className="right-actions">
<Tooltip title="Reset All">
<div className="right-action-icon-container">
<SyncOutlined className="sync-icon" onClick={handleReset} />
</div>
</Tooltip>
)}
<Tooltip title="Collapse Filters">
<div className="right-action-icon-container">
<VerticalAlignTopOutlined
rotate={270}
onClick={handleFilterVisibilityChange}
/>
</div>
</Tooltip>
{isDynamicFilters && (
<Tooltip title="Settings">
<div
className={classNames('right-action-icon-container', {
active: isSettingsOpen,
})}
>
<SettingsIcon
className="settings-icon"
data-testid="settings-icon"
width={14}
height={14}
onClick={(): void => setIsSettingsOpen(true)}
/>
<AnnouncementTooltip
show={showAnnouncementTooltip}
position={{ top: -5, left: 15 }}
title="Edit your quick filters"
message="You can now customize and re-arrange your quick filters panel. Select the quick filters youd need and hide away the rest for faster exploration."
onClose={(): void => {
setLocalStorageKey(
LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT,
'false',
);
}}
/>
</div>
</Tooltip>
)}
</section>
</section>
)}
<section className="right-actions">
<Tooltip title="Reset All">
<SyncOutlined className="sync-icon" onClick={handleReset} />
</Tooltip>
<div className="divider-filter" />
<Tooltip title="Collapse Filters">
<VerticalAlignTopOutlined
rotate={270}
onClick={handleFilterVisibilityChange}
/>
</Tooltip>
{isCustomFiltersLoading ? (
<div className="quick-filters-skeleton">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton.Input
active
size="small"
// eslint-disable-next-line react/no-array-index-key
key={index}
/>
))}
</div>
) : (
<OverlayScrollbar>
<section className="filters">
{filterConfig.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.SLIDER:
return <Slider filter={filter} />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
default:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
}
})}
</section>
</section>
</OverlayScrollbar>
)}
<TypicalOverlayScrollbar>
<section className="filters">
{config.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.SLIDER:
return <Slider filter={filter} />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
default:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
}
})}
</section>
</TypicalOverlayScrollbar>
</div>
<div className="quick-filters-settings-container">
<div
className={classNames(
'quick-filters-settings',
{
hidden: !isSettingsOpen,
},
className,
)}
>
{isSettingsOpen && (
<QuickFiltersSettings
signal={signal}
setIsSettingsOpen={setIsSettingsOpen}
customFilters={customFilters}
setIsStale={setIsStale}
/>
)}
</div>
</div>
</div>
);
}
QuickFilters.defaultProps = {
onFilterChange: null,
signal: '',
config: [],
};

View File

@@ -0,0 +1,147 @@
/* eslint-disable react/jsx-props-no-spreading */
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from 'lucide-react';
import { useMemo } from 'react';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
function SortableFilter({
filter,
onRemove,
allowDrag,
allowRemove,
}: {
filter: FilterType;
onRemove: (filter: FilterType) => void;
allowDrag: boolean;
allowRemove: boolean;
}): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: filter.key });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`qf-filter-item ${allowDrag ? 'drag-enabled' : 'drag-disabled'}`}
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={16} />}
{filter.key}
</div>
{allowRemove && (
<Button
className="remove-filter-btn periscope-btn"
size="small"
onClick={(): void => {
onRemove(filter as FilterType);
}}
>
Remove
</Button>
)}
</div>
);
}
function AddedFilters({
inputValue,
addedFilters,
setAddedFilters,
}: {
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
}): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (over && active.id !== over.id) {
setAddedFilters((items) => {
const oldIndex = items.findIndex((item) => item.key === active.id);
const newIndex = items.findIndex((item) => item.key === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const filteredAddedFilters = useMemo(
() =>
addedFilters.filter((filter) =>
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
),
[addedFilters, inputValue],
);
const handleRemoveFilter = (filter: FilterType): void => {
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
};
const allowDrag = inputValue.length === 0;
const allowRemove = addedFilters.length > 1;
return (
<div className="qf-filters added-filters">
<div className="qf-filters-header">ADDED FILTERS</div>
<div className="qf-added-filters-list">
<OverlayScrollbar>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
{filteredAddedFilters.length === 0 ? (
<div className="no-values-found">No values found</div>
) : (
<SortableContext
items={addedFilters.map((f) => f.key)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>
{filteredAddedFilters.map((filter) => (
<SortableFilter
key={filter.key}
filter={filter}
onRemove={handleRemoveFilter}
allowDrag={allowDrag}
allowRemove={allowRemove}
/>
))}
</SortableContext>
)}
</DndContext>
</OverlayScrollbar>
</div>
</div>
);
}
export default AddedFilters;

View File

@@ -0,0 +1,56 @@
.announcement-tooltip {
&__dot {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--bg-robin-500);
z-index: 1000;
}
&__title {
font-weight: 500;
font-size: 15px;
}
&__container {
position: absolute;
width: 320px;
background-color: var(--bg-robin-500);
color: var(--text-white);
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
}
&__close-icon {
cursor: pointer;
color: var(--text-white);
}
&__message {
margin: 12px 0;
line-height: 20px;
}
&__footer {
display: flex;
justify-content: flex-end;
}
&__button {
background: var(--bg-vanilla-100);
color: var(--bg-robin-500);
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
}

View File

@@ -0,0 +1,79 @@
import './AnnouncementTooltip.styles.scss';
import { Button, Typography } from 'antd';
import classNames from 'classnames';
import { X } from 'lucide-react';
import { useState } from 'react';
type AnnouncementTooltipProps = {
position: { top: number; left: number };
title: string;
message: string;
show?: boolean;
className?: string;
onClose?: () => void;
};
// TEMPORARY HACK FOR ANNOUNCEMENTS: To be removed once proper system in place.
function AnnouncementTooltip({
position,
show,
title,
message,
className,
onClose,
}: AnnouncementTooltipProps): JSX.Element | null {
const [visible, setVisible] = useState(show);
const closeTooltip = (): void => {
setVisible(false);
onClose?.();
};
return visible ? (
<>
{/* Dot */}
<div
className={classNames('announcement-tooltip__dot', className)}
style={{
top: position.top,
left: position.left,
}}
/>
{/* Tooltip box */}
<div
className={classNames('announcement-tooltip__container', className)}
style={{
top: position.top,
left: position.left + 30,
}}
>
<div className="announcement-tooltip__header">
<Typography.Text className="announcement-tooltip__title">
{title}
</Typography.Text>
<X
size={18}
onClick={closeTooltip}
className="announcement-tooltip__close-icon"
/>
</div>
<p className="announcement-tooltip__message">{message}</p>
<div className="announcement-tooltip__footer">
<Button onClick={closeTooltip} className="announcement-tooltip__button">
Okay
</Button>
</div>
</div>
</>
) : null;
}
AnnouncementTooltip.defaultProps = {
show: false,
className: '',
onClose: (): void => {},
};
export default AnnouncementTooltip;

View File

@@ -0,0 +1,104 @@
import { Button, Skeleton } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
import { useMemo } from 'react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
function OtherFiltersSkeleton(): JSX.Element {
return (
<>
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton.Input
active
size="small"
// eslint-disable-next-line react/no-array-index-key
key={index}
/>
))}
</>
);
}
function OtherFilters({
signal,
inputValue,
addedFilters,
setAddedFilters,
}: {
signal: SignalType | undefined;
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
}): JSX.Element {
const {
data: suggestionsData,
isFetching: isFetchingSuggestions,
} = useGetAttributeSuggestions(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
filters: {} as TagFilter,
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal,
},
);
const otherFilters = useMemo(
() =>
suggestionsData?.payload?.attributes?.filter(
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
),
[suggestionsData, addedFilters],
);
const handleAddFilter = (filter: FilterType): void => {
setAddedFilters((prev) => [
...prev,
{
key: filter.key,
dataType: filter.dataType,
isColumn: filter.isColumn,
isJSON: filter.isJSON,
type: filter.type,
},
]);
};
const renderFilters = (): React.ReactNode => {
if (isFetchingSuggestions) return <OtherFiltersSkeleton />;
if (!otherFilters?.length)
return <div className="no-values-found">No values found</div>;
return otherFilters.map((filter) => (
<div key={filter.key} className="qf-filter-item other-filters-item">
<div className="qf-filter-key">{filter.key}</div>
<Button
className="add-filter-btn periscope-btn"
size="small"
onClick={(): void => handleAddFilter(filter as FilterType)}
>
Add
</Button>
</div>
));
};
return (
<div className="qf-filters other-filters">
<div className="qf-filters-header">OTHER FILTERS</div>
<div className="qf-other-filters-list">
<OverlayScrollbar>
<>{renderFilters()}</>
</OverlayScrollbar>
</div>
</div>
);
}
export default OtherFilters;

View File

@@ -0,0 +1,190 @@
.quick-filters-settings {
display: flex;
flex-direction: column;
position: absolute;
z-index: 999;
width: 342px;
background: var(--bg-slate-500);
transition: width 0.05s ease-in-out;
overflow: hidden;
&.qf-logs-explorer {
height: calc(100vh - 45px);
}
&.qf-exceptions {
height: 100vh;
}
&.hidden {
width: 0;
}
.qf-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10.5px;
.qf-title {
display: flex;
align-items: center;
gap: 12px;
}
.qf-header-icon {
width: 16px;
height: 16px;
cursor: pointer;
}
}
.qf-filters {
&.added-filters {
max-height: 40%;
}
&.other-filters {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
}
.search {
.ant-input {
background-color: var(--bg-slate-500);
height: 46px;
border-radius: 0;
}
}
.qf-other-filters-list {
.ant-skeleton-input {
width: 300px;
margin: 8px 12px;
}
}
.qf-footer {
display: flex;
gap: 12px;
padding: 12px;
border-top: 1px solid var(--bg-slate-400);
button {
display: flex;
align-items: center;
justify-content: center;
width: 50%;
.ant-btn-icon {
margin: 3px !important;
}
}
}
}
//ADDED FILTERS AND OTHER FILTERS COMMON STYLES
.qf-filters {
display: flex;
flex-direction: column;
.qf-filters-header {
color: var(--bg-slate-50);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 8px 12px;
}
}
.no-values-found {
padding: 8px;
text-align: center;
}
.qf-added-filters-list {
margin-bottom: 12px;
overflow: hidden;
}
.qf-other-filters-list {
margin-bottom: 12px;
overflow: hidden;
}
.qf-filter-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-radius: 4px;
user-select: none;
transition: background-color 0.2s ease-in-out;
.drag-handle {
display: flex;
align-items: center;
gap: 8px;
flex-grow: 1;
}
.qf-filter-content {
display: flex;
align-items: center;
gap: 8px;
}
&.other-filters-item {
padding: 8px 12px;
height: 32px;
}
&.drag-enabled {
cursor: grab;
&:active {
cursor: grabbing;
}
}
&.drag-disabled {
padding: 8px 12px;
}
.remove-filter-btn,
.add-filter-btn {
padding: 6px 12px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
&:hover {
background-color: var(--bg-slate-400);
.remove-filter-btn,
.add-filter-btn {
opacity: 1;
}
}
}
.lightMode {
.quick-filters-settings {
background: var(--bg-vanilla-100);
.search {
.ant-input {
background-color: var(--bg-vanilla-100);
}
}
.qf-footer {
border-top: 1px solid var(--bg-vanilla-300);
}
}
.qf-filter-item {
&:hover {
background-color: var(--bg-vanilla-200);
}
}
}

View File

@@ -0,0 +1,109 @@
import './QuickFiltersSettings.styles.scss';
import { Button, Input } from 'antd';
import { CheckIcon, TableColumnsSplit, XIcon } from 'lucide-react';
import { useMemo } from 'react';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { SignalType } from '../types';
import AddedFilters from './AddedFilters';
import useQuickFilterSettings from './hooks/useQuickFilterSettings';
import OtherFilters from './OtherFilters';
function QuickFiltersSettings({
signal,
setIsSettingsOpen,
customFilters,
setIsStale,
}: {
signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
setIsStale: (isStale: boolean) => void;
}): JSX.Element {
const {
handleSettingsClose,
handleDiscardChanges,
addedFilters,
setAddedFilters,
handleSaveChanges,
isUpdatingCustomFilters,
inputValue,
handleInputChange,
debouncedInputValue,
} = useQuickFilterSettings({
setIsSettingsOpen,
customFilters,
setIsStale,
signal,
});
const hasUnsavedChanges = useMemo(
() =>
// check if both arrays have the same length and same order of elements
!(
addedFilters.length === customFilters.length &&
addedFilters.every(
(filter, index) => filter.key === customFilters[index].key,
)
),
[addedFilters, customFilters],
);
return (
<>
<div className="qf-header">
<div className="qf-title">
<TableColumnsSplit width={16} height={16} />
Edit quick filters
</div>
<XIcon
className="qf-header-icon"
width={16}
height={16}
onClick={handleSettingsClose}
/>
</div>
<section className="search">
<Input
type="text"
value={inputValue}
placeholder="Search for a filter..."
onChange={handleInputChange}
/>
</section>
<AddedFilters
inputValue={inputValue}
addedFilters={addedFilters}
setAddedFilters={setAddedFilters}
/>
<OtherFilters
signal={signal}
inputValue={debouncedInputValue}
addedFilters={addedFilters}
setAddedFilters={setAddedFilters}
/>
{hasUnsavedChanges && (
<div className="qf-footer">
<Button
type="default"
onClick={handleDiscardChanges}
icon={<XIcon width={16} height={16} />}
>
Discard
</Button>
<Button
type="primary"
onClick={handleSaveChanges}
icon={<CheckIcon width={16} height={16} />}
loading={isUpdatingCustomFilters}
>
Save changes
</Button>
</div>
)}
</>
);
}
export default QuickFiltersSettings;

View File

@@ -0,0 +1,9 @@
import { SignalType } from 'components/QuickFilters/types';
import { DataSource } from 'types/common/queryBuilder';
export const SIGNAL_DATA_SOURCE_MAP = {
[SignalType.LOGS]: DataSource.LOGS,
[SignalType.TRACES]: DataSource.TRACES,
[SignalType.EXCEPTIONS]: DataSource.TRACES,
[SignalType.API_MONITORING]: DataSource.TRACES,
};

View File

@@ -0,0 +1,111 @@
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
import axios, { AxiosError } from 'axios';
import { SignalType } from 'components/QuickFilters/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { useCallback, useState } from 'react';
import { useMutation } from 'react-query';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
setIsStale: (isStale: boolean) => void;
signal?: SignalType;
}
interface UseQuickFilterSettingsReturn {
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
handleSettingsClose: () => void;
handleDiscardChanges: () => void;
handleSaveChanges: () => void;
isUpdatingCustomFilters: boolean;
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
debouncedInputValue: string;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const useQuickFilterSettings = ({
customFilters,
setIsSettingsOpen,
setIsStale,
signal,
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
const { notifications } = useNotifications();
const {
mutate: updateCustomFilters,
isLoading: isUpdatingCustomFilters,
} = useMutation(updateCustomFiltersAPI, {
onSuccess: () => {
setIsSettingsOpen(false);
setIsStale(true);
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error: AxiosError) => {
notifications.error({
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
},
});
const debouncedUpdate = useDebouncedFn((value) => {
setDebouncedInputValue(value as string);
}, 400);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
setInputValue(value);
debouncedUpdate(value);
},
[debouncedUpdate],
);
const handleSettingsClose = useCallback((): void => {
setIsSettingsOpen(false);
}, [setIsSettingsOpen]);
const handleDiscardChanges = useCallback((): void => {
setAddedFilters(customFilters);
}, [customFilters, setAddedFilters]);
const handleSaveChanges = useCallback((): void => {
if (signal) {
updateCustomFilters({
data: {
filters: addedFilters.map((filter) => ({
key: filter.key,
datatype: filter.dataType,
type: filter.type,
})),
signal,
},
});
}
}, [addedFilters, signal, updateCustomFilters]);
return {
handleSettingsClose,
handleDiscardChanges,
addedFilters,
setAddedFilters,
handleSaveChanges,
isUpdatingCustomFilters,
inputValue,
setInputValue,
debouncedInputValue,
handleInputChange,
};
};
export default useQuickFilterSettings;

View File

@@ -0,0 +1,67 @@
import getCustomFilters from 'api/quickFilters/getCustomFilters';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
Filter as FilterType,
PayloadProps,
} from 'types/api/quickFilters/getCustomFilters';
import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils';
interface UseFilterConfigProps {
signal?: SignalType;
config: IQuickFiltersConfig[];
}
interface UseFilterConfigReturn {
filterConfig: IQuickFiltersConfig[];
customFilters: FilterType[];
setCustomFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
isCustomFiltersLoading: boolean;
isDynamicFilters: boolean;
setIsStale: React.Dispatch<React.SetStateAction<boolean>>;
}
const useFilterConfig = ({
signal,
config,
}: UseFilterConfigProps): UseFilterConfigReturn => {
const [customFilters, setCustomFilters] = useState<FilterType[]>([]);
const [isStale, setIsStale] = useState(true);
const isDynamicFilters = useMemo(() => customFilters.length > 0, [
customFilters,
]);
const { isLoading: isCustomFiltersLoading } = useQuery<
SuccessResponse<PayloadProps> | ErrorResponse,
Error
>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
() => getCustomFilters({ signal: signal || '' }),
{
onSuccess: (data) => {
if ('payload' in data && data.payload?.filters) {
setCustomFilters(data.payload.filters || ([] as FilterType[]));
}
setIsStale(false);
},
enabled: !!signal && isStale,
},
);
const filterConfig = useMemo(() => getFilterConfig(customFilters, config), [
config,
customFilters,
]);
return {
filterConfig,
customFilters,
setCustomFilters,
isCustomFiltersLoading,
isDynamicFilters,
setIsStale,
};
};
export default useFilterConfig;

View File

@@ -1,111 +1,288 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
otherFiltersResponse,
quickFiltersAttributeValuesResponse,
quickFiltersListResponse,
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import QuickFilters from '../QuickFilters';
import { QuickFiltersSource } from '../types';
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersConfig } from './constants';
// Mock the useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
// Mock the useGetAggregateValues hook
jest.mock('hooks/queryBuilder/useGetAggregateValues', () => ({
useGetAggregateValues: jest.fn(),
}));
const handleFilterVisibilityChange = jest.fn();
const redirectWithQueryBuilderData = jest.fn();
const putHandler = jest.fn();
function TestQuickFilters(): JSX.Element {
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const FILTER_OS_DESCRIPTION = 'os.description';
const FILTER_K8S_DEPLOYMENT_NAME = 'k8s.deployment.name';
const ADDED_FILTERS_LABEL = /ADDED FILTERS/i;
const OTHER_FILTERS_LABEL = /OTHER FILTERS/i;
const SAVE_CHANGES_TEXT = 'Save changes';
const DISCARD_TEXT = 'Discard';
const FILTER_SERVICE_NAME = 'Service Name';
const SETTINGS_ICON_TEST_ID = 'settings-icon';
const QUERY_NAME = 'Test Query';
const setupServer = (): void => {
server.use(
rest.get(quickFiltersListURL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersListResponse)),
),
rest.get(quickFiltersSuggestionsURL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(otherFiltersResponse)),
),
rest.put(saveQuickFiltersURL, async (req, res, ctx) => {
putHandler(await req.json());
return res(ctx.status(200), ctx.json({}));
}),
rest.get(quickFiltersAttributeValuesURL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
);
};
function TestQuickFilters({
signal = SignalType.LOGS,
config = QuickFiltersConfig,
}: {
signal?: SignalType;
config?: IQuickFiltersConfig[];
}): JSX.Element {
return (
<MockQueryClientProvider>
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
config={QuickFiltersConfig}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
signal={signal}
/>
</MockQueryClientProvider>
);
}
describe('Quick Filters', () => {
beforeEach(() => {
// Provide a mock implementation for useQueryBuilder
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
queryName: 'Test Query',
filters: { items: [{ key: 'test', value: 'value' }] },
},
],
},
TestQuickFilters.defaultProps = {
signal: '',
config: QuickFiltersConfig,
};
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
cleanup();
});
beforeEach(() => {
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
queryName: QUERY_NAME,
filters: { items: [{ key: 'test', value: 'value' }] },
},
],
},
lastUsedQuery: 0,
redirectWithQueryBuilderData,
});
// Provide a mock implementation for useGetAggregateValues
(useGetAggregateValues as jest.Mock).mockReturnValue({
data: {
statusCode: 200,
error: null,
message: 'success',
payload: {
stringAttributeValues: [
'mq-kafka',
'otel-demo',
'otlp-python',
'sample-flask',
],
numberAttributeValues: null,
boolAttributeValues: null,
},
}, // Mocked API response
isLoading: false,
});
});
it('renders correctly with default props', () => {
const { container } = render(<TestQuickFilters />);
expect(container).toMatchSnapshot();
},
lastUsedQuery: 0,
redirectWithQueryBuilderData,
});
setupServer();
});
describe('Quick Filters', () => {
it('displays the correct query name in the header', () => {
render(<TestQuickFilters />);
expect(screen.getByText('Filters for')).toBeInTheDocument();
expect(screen.getByText('Test Query')).toBeInTheDocument();
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
});
it('should add filter data to query when checkbox is clicked', () => {
it('should add filter data to query when checkbox is clicked', async () => {
render(<TestQuickFilters />);
const checkbox = screen.getByText('mq-kafka');
fireEvent.click(checkbox);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: {
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({
key: 'deployment.environment',
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: {
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({
key: 'deployment.environment',
}),
value: 'mq-kafka',
}),
value: 'mq-kafka',
}),
]),
]),
}),
}),
}),
]),
},
}),
); // sets composite query param
]),
},
}),
);
});
});
});
describe('Quick Filters with custom filters', () => {
it('loads the custom filters correctly', async () => {
render(<TestQuickFilters signal={SIGNAL} />);
expect(screen.getByText('Filters for')).toBeInTheDocument();
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
await screen.findByText(FILTER_SERVICE_NAME);
await screen.findByText('otel-demo');
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
expect(await screen.findByText('Edit quick filters')).toBeInTheDocument();
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
expect(addedSection).toContainElement(
await screen.findByText(FILTER_OS_DESCRIPTION),
);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
expect(otherSection).toContainElement(
await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME),
);
});
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const otherFilterItem = await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME);
const addButton = otherFilterItem.parentElement?.querySelector('button');
expect(addButton).not.toBeNull();
fireEvent.click(addButton as HTMLButtonElement);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
await waitFor(() => {
expect(addedSection).toHaveTextContent(FILTER_K8S_DEPLOYMENT_NAME);
});
});
it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => {
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement);
await waitFor(() => {
expect(addedSection).not.toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
});
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
expect(otherSection).toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
});
it('restores original filter state on Discard', async () => {
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
await waitFor(() => {
expect(addedSection).not.toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
expect(otherSection).toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
});
fireEvent.click(screen.getByText(DISCARD_TEXT));
await waitFor(() => {
expect(addedSection).toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
expect(otherSection).not.toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
});
});
it('saves the updated filters by calling PUT with correct payload', async () => {
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement);
fireEvent.click(screen.getByText(SAVE_CHANGES_TEXT));
await waitFor(() => {
expect(putHandler).toHaveBeenCalled();
});
const requestBody = putHandler.mock.calls[0][0];
expect(requestBody.filters).toEqual(
expect.arrayContaining([
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
]),
);
expect(requestBody.signal).toBe(SIGNAL);
});
});

View File

@@ -1,384 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Quick Filters renders correctly with default props 1`] = `
<div>
<div
class="quick-filters"
>
<section
class="header"
>
<section
class="left-actions"
>
<span
aria-label="filter"
class="anticon anticon-filter"
role="img"
>
<svg
aria-hidden="true"
data-icon="filter"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M880.1 154H143.9c-24.5 0-39.8 26.7-27.5 48L349 597.4V838c0 17.7 14.2 32 31.8 32h262.4c17.6 0 31.8-14.3 31.8-32V597.4L907.7 202c12.2-21.3-3.1-48-27.6-48zM603.4 798H420.6V642h182.9v156zm9.6-236.6l-9.5 16.6h-183l-9.5-16.6L212.7 226h598.6L613 561.4z"
/>
</svg>
</span>
<span
class="ant-typography text css-dev-only-do-not-override-2i2tap"
>
Filters for
</span>
<span
class="ant-typography sync-tag css-dev-only-do-not-override-2i2tap"
>
Test Query
</span>
</section>
<section
class="right-actions"
>
<span
aria-label="sync"
class="anticon anticon-sync sync-icon"
role="img"
tabindex="-1"
>
<svg
aria-hidden="true"
data-icon="sync"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M168 504.2c1-43.7 10-86.1 26.9-126 17.3-41 42.1-77.7 73.7-109.4S337 212.3 378 195c42.4-17.9 87.4-27 133.9-27s91.5 9.1 133.8 27A341.5 341.5 0 01755 268.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.7 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c0-6.7-7.7-10.5-12.9-6.3l-56.4 44.1C765.8 155.1 646.2 92 511.8 92 282.7 92 96.3 275.6 92 503.8a8 8 0 008 8.2h60c4.4 0 7.9-3.5 8-7.8zm756 7.8h-60c-4.4 0-7.9 3.5-8 7.8-1 43.7-10 86.1-26.9 126-17.3 41-42.1 77.8-73.7 109.4A342.45 342.45 0 01512.1 856a342.24 342.24 0 01-243.2-100.8c-9.9-9.9-19.2-20.4-27.8-31.4l60.2-47a8 8 0 00-3-14.1l-175.7-43c-5-1.2-9.9 2.6-9.9 7.7l-.7 181c0 6.7 7.7 10.5 12.9 6.3l56.4-44.1C258.2 868.9 377.8 932 512.2 932c229.2 0 415.5-183.7 419.8-411.8a8 8 0 00-8-8.2z"
/>
</svg>
</span>
<div
class="divider-filter"
/>
<span
aria-label="vertical-align-top"
class="anticon anticon-vertical-align-top"
role="img"
tabindex="-1"
>
<svg
aria-hidden="true"
data-icon="vertical-align-top"
fill="currentColor"
focusable="false"
height="1em"
style="transform: rotate(270deg);"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M859.9 168H164.1c-4.5 0-8.1 3.6-8.1 8v60c0 4.4 3.6 8 8.1 8h695.8c4.5 0 8.1-3.6 8.1-8v-60c0-4.4-3.6-8-8.1-8zM518.3 355a8 8 0 00-12.6 0l-112 141.7a7.98 7.98 0 006.3 12.9h73.9V848c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V509.7H624c6.7 0 10.4-7.7 6.3-12.9L518.3 355z"
/>
</svg>
</span>
</section>
</section>
<div
class="overlay-scrollbar"
data-overlayscrollbars-initialize="true"
>
<div
data-overlayscrollbars-contents=""
>
<section
class="filters"
>
<div
class="checkbox-filter"
>
<section
class="filter-header-checkbox"
>
<section
class="left-action"
>
<svg
aria-hidden="true"
class="lucide lucide-chevron-down"
cursor="pointer"
fill="none"
height="13"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="13"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
<span
class="ant-typography title css-dev-only-do-not-override-2i2tap"
>
Environment
</span>
</section>
<section
class="right-action"
>
<span
class="ant-typography clear-all css-dev-only-do-not-override-2i2tap"
>
Clear All
</span>
</section>
</section>
<section
class="search"
>
<input
class="ant-input css-dev-only-do-not-override-2i2tap"
placeholder="Filter values"
type="text"
value=""
/>
</section>
<section
class="values"
>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
mq-kafka
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
otel-demo
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
otlp-python
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
sample-flask
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
</section>
</div>
<div
class="checkbox-filter"
>
<section
class="filter-header-checkbox"
>
<section
class="left-action"
>
<svg
aria-hidden="true"
class="lucide lucide-chevron-right"
cursor="pointer"
fill="none"
height="13"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="13"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m9 18 6-6-6-6"
/>
</svg>
<span
class="ant-typography title css-dev-only-do-not-override-2i2tap"
>
Service Name
</span>
</section>
<section
class="right-action"
/>
</section>
</div>
</section>
</div>
</div>
</div>
</div>
`;

View File

@@ -17,6 +17,13 @@ export enum SpecficFilterOperations {
ONLY = 'ONLY',
}
export enum SignalType {
TRACES = 'traces',
LOGS = 'logs',
API_MONITORING = 'api_monitoring',
EXCEPTIONS = 'exceptions',
}
export interface IQuickFiltersConfig {
type: FiltersType;
title: string;
@@ -33,6 +40,8 @@ export interface IQuickFiltersProps {
handleFilterVisibilityChange: () => void;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
signal?: SignalType;
className?: string;
}
export enum QuickFiltersSource {

View File

@@ -0,0 +1,39 @@
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { FiltersType, IQuickFiltersConfig } from './types';
const getFilterName = (str: string): string =>
// replace . and _ with space
// capitalize the first letter of each word
str
.replace(/\./g, ' ')
.replace(/_/g, ' ')
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
export const getFilterConfig = (
customFilters?: FilterType[],
config?: IQuickFiltersConfig[],
): IQuickFiltersConfig[] => {
if (!customFilters?.length) {
return config || [];
}
return customFilters.map(
(att, index) =>
({
type: FiltersType.CHECKBOX,
title: getFilterName(att.key),
attributeKey: {
id: att.key,
key: att.key,
dataType: att.dataType,
type: att.type,
isColumn: att.isColumn,
isJSON: att.isJSON,
},
defaultOpen: index === 0,
} as IQuickFiltersConfig),
);
};

View File

@@ -28,4 +28,7 @@ export enum LOCALSTORAGE {
DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING',
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
BANNER_DISMISSED = 'BANNER_DISMISSED',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
UNEXECUTED_FUNNELS = 'UNEXECUTED_FUNNELS',
}

View File

@@ -75,7 +75,12 @@ export const REACT_QUERY_KEY = {
VALIDATE_FUNNEL_STEPS: 'VALIDATE_FUNNEL_STEPS',
UPDATE_FUNNEL_STEP_DETAILS: 'UPDATE_FUNNEL_STEP_DETAILS',
GET_FUNNEL_OVERVIEW: 'GET_FUNNEL_OVERVIEW',
GET_FUNNEL_STEPS_OVERVIEW: 'GET_FUNNEL_STEPS_OVERVIEW',
GET_FUNNEL_SLOW_TRACES: 'GET_FUNNEL_SLOW_TRACES',
GET_FUNNEL_ERROR_TRACES: 'GET_FUNNEL_ERROR_TRACES',
GET_FUNNEL_STEPS_GRAPH_DATA: 'GET_FUNNEL_STEPS_GRAPH_DATA',
// Quick Filters Query Keys
GET_CUSTOM_FILTERS: 'GET_CUSTOM_FILTERS',
GET_OTHER_FILTERS: 'GET_OTHER_FILTERS',
} as const;

View File

@@ -1,3 +1,8 @@
import {
IntegrationType,
RequestIntegrationBtn,
} from 'pages/Integrations/RequestIntegrationBtn';
import Header from './Header/Header';
import HeroSection from './HeroSection/HeroSection';
import ServicesTabs from './ServicesSection/ServicesTabs';
@@ -7,6 +12,10 @@ function CloudIntegrationPage(): JSX.Element {
<div>
<Header />
<HeroSection />
<RequestIntegrationBtn
type={IntegrationType.AWS_SERVICES}
message="Cannot find the AWS service you're looking for? Request more integrations"
/>
<ServicesTabs />
</div>
);

View File

@@ -33,13 +33,16 @@
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
border-radius: 2px;
color: var(--bg-vanilla-400);
font-size: 12px;
line-height: 10px; /* 83.333% */
line-height: 10px;
letter-spacing: 0.12px;
width: 113px;
height: 32px;
cursor: pointer;
&,
&:hover {
color: var(--bg-vanilla-400);
}
}
}

View File

@@ -33,10 +33,15 @@ function Header(): JSX.Element {
/>
</div>
<div className="cloud-header__actions">
<button className="cloud-header__help" type="button">
<a
href="https://signoz.io/blog/native-aws-integrations-with-autodiscovery/"
target="_blank"
rel="noopener noreferrer"
className="cloud-header__help"
>
<LifeBuoy size={12} />
Get Help
</button>
</a>
</div>
</div>
);

View File

@@ -58,6 +58,23 @@ function AccountSettingsModal({
});
};
const handleRegionDeselect = useCallback(
(item: string): void => {
if (selectedRegions.includes(item)) {
setSelectedRegions(selectedRegions.filter((region) => region !== item));
if (includeAllRegions) {
setIncludeAllRegions(false);
}
}
},
[
selectedRegions,
includeAllRegions,
setSelectedRegions,
setIncludeAllRegions,
],
);
const renderRegionSelector = useCallback(() => {
if (isRegionSelectOpen) {
return (
@@ -93,17 +110,19 @@ function AccountSettingsModal({
maxTagCount={3}
value={getRegionPreviewText(selectedRegions)}
open={false}
onDeselect={handleRegionDeselect}
/>
</>
);
}, [
isRegionSelectOpen,
selectedRegions,
includeAllRegions,
handleIncludeAllRegionsChange,
setIsRegionSelectOpen,
selectedRegions,
handleRegionDeselect,
setSelectedRegions,
setIncludeAllRegions,
setIsRegionSelectOpen,
]);
const renderAccountDetails = useCallback(

View File

@@ -8,12 +8,14 @@ import {
SupportedSignals,
} from 'container/CloudIntegrationPage/ServicesSection/types';
import { useUpdateServiceConfig } from 'hooks/integration/aws/useUpdateServiceConfig';
import { isEqual } from 'lodash-es';
import { useCallback, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import logEvent from '../../../api/common/logEvent';
import S3BucketsSelector from './S3BucketsSelector';
interface IConfigureServiceModalProps {
export interface IConfigureServiceModalProps {
isOpen: boolean;
onClose: () => void;
serviceName: string;
@@ -36,18 +38,34 @@ function ConfigureServiceModal({
const [isLoading, setIsLoading] = useState(false);
// Track current form values
const initialValues = {
metrics: initialConfig?.metrics?.enabled || false,
logs: initialConfig?.logs?.enabled || false,
};
const initialValues = useMemo(
() => ({
metrics: initialConfig?.metrics?.enabled || false,
logs: initialConfig?.logs?.enabled || false,
s3Buckets: initialConfig?.logs?.s3_buckets || {},
}),
[initialConfig],
);
const [currentValues, setCurrentValues] = useState(initialValues);
const isSaveDisabled = useMemo(
() =>
// disable only if current values are same as the initial config
currentValues.metrics === initialValues.metrics &&
currentValues.logs === initialValues.logs,
[currentValues, initialValues.metrics, initialValues.logs],
currentValues.logs === initialValues.logs &&
isEqual(currentValues.s3Buckets, initialValues.s3Buckets),
[currentValues, initialValues],
);
const handleS3BucketsChange = useCallback(
(bucketsByRegion: Record<string, string[]>) => {
setCurrentValues((prev) => ({
...prev,
s3Buckets: bucketsByRegion,
}));
form.setFieldsValue({ s3Buckets: bucketsByRegion });
},
[form],
);
const {
@@ -70,6 +88,7 @@ function ConfigureServiceModal({
config: {
logs: {
enabled: values.logs,
s3_buckets: values.s3Buckets,
},
metrics: {
enabled: values.metrics,
@@ -144,6 +163,7 @@ function ConfigureServiceModal({
initialValues={{
metrics: initialConfig?.metrics?.enabled || false,
logs: initialConfig?.logs?.enabled || false,
s3Buckets: initialConfig?.logs?.s3_buckets || {},
}}
>
<div className=" configure-service-modal__body">
@@ -174,27 +194,38 @@ function ConfigureServiceModal({
)}
{supportedSignals.logs && (
<Form.Item
name="logs"
valuePropName="checked"
className="configure-service-modal__body-form-item"
>
<div className="configure-service-modal__body-regions-switch-switch">
<Switch
checked={currentValues.logs}
onChange={(checked): void => {
setCurrentValues((prev) => ({ ...prev, logs: checked }));
form.setFieldsValue({ logs: checked });
}}
/>
<span className="configure-service-modal__body-regions-switch-switch-label">
Log Collection
</span>
</div>
<div className="configure-service-modal__body-switch-description">
To ingest logs from your AWS services, you must complete several steps
</div>
</Form.Item>
<>
<Form.Item
name="logs"
valuePropName="checked"
className="configure-service-modal__body-form-item"
>
<div className="configure-service-modal__body-regions-switch-switch">
<Switch
checked={currentValues.logs}
onChange={(checked): void => {
setCurrentValues((prev) => ({ ...prev, logs: checked }));
form.setFieldsValue({ logs: checked });
}}
/>
<span className="configure-service-modal__body-regions-switch-switch-label">
Log Collection
</span>
</div>
<div className="configure-service-modal__body-switch-description">
To ingest logs from your AWS services, you must complete several steps
</div>
</Form.Item>
{currentValues.logs && serviceId === 's3sync' && (
<Form.Item name="s3Buckets" noStyle>
<S3BucketsSelector
initialBucketsByRegion={currentValues.s3Buckets}
onChange={handleS3BucketsChange}
/>
</Form.Item>
)}
</>
)}
</div>
</Form>

View File

@@ -0,0 +1,123 @@
import { Form, Select, Skeleton, Typography } from 'antd';
import { useAwsAccounts } from 'hooks/integration/aws/useAwsAccounts';
import useUrlQuery from 'hooks/useUrlQuery';
import { useCallback, useMemo, useState } from 'react';
const { Title } = Typography;
interface S3BucketsSelectorProps {
onChange?: (bucketsByRegion: Record<string, string[]>) => void;
initialBucketsByRegion?: Record<string, string[]>;
}
/**
* Component for selecting S3 buckets by AWS region
* Displays a multi-select input for each region in the active AWS account
*/
function S3BucketsSelector({
onChange,
initialBucketsByRegion = {},
}: S3BucketsSelectorProps): JSX.Element {
const cloudAccountId = useUrlQuery().get('cloudAccountId');
const { data: accounts, isLoading } = useAwsAccounts();
const [bucketsByRegion, setBucketsByRegion] = useState<
Record<string, string[]>
>(initialBucketsByRegion);
// Find the active AWS account based on the URL query parameter
const activeAccount = useMemo(
() =>
accounts?.find((account) => account.cloud_account_id === cloudAccountId),
[accounts, cloudAccountId],
);
// Get all regions to display (union of account regions and initialBucketsByRegion regions)
const allRegions = useMemo(() => {
if (!activeAccount) return [];
// Get unique regions from both sources
const initialRegions = Object.keys(initialBucketsByRegion);
const accountRegions = activeAccount.config.regions;
// Create a Set to get unique values
const uniqueRegions = new Set([...accountRegions, ...initialRegions]);
return Array.from(uniqueRegions);
}, [activeAccount, initialBucketsByRegion]);
// Check if a region is disabled (not in account's regions)
const isRegionDisabled = useCallback(
(region: string) => !activeAccount?.config.regions.includes(region),
[activeAccount],
);
// Handle changes to bucket selections for a specific region
const handleRegionBucketsChange = useCallback(
(region: string, buckets: string[]): void => {
setBucketsByRegion((prevBuckets) => {
const updatedBuckets = { ...prevBuckets };
if (buckets.length === 0) {
// Remove empty bucket arrays
delete updatedBuckets[region];
} else {
updatedBuckets[region] = buckets;
}
// Notify parent component of changes
onChange?.(updatedBuckets);
return updatedBuckets;
});
},
[onChange],
);
// Show loading state while fetching account data
if (isLoading || !activeAccount) {
return <Skeleton active />;
}
return (
<div className="s3-buckets-selector">
<Title level={5}>Select S3 Buckets by Region</Title>
{allRegions.map((region) => {
const disabled = isRegionDisabled(region);
return (
<Form.Item
key={region}
label={region}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(disabled && {
help:
'Region disabled in account settings; S3 buckets here will not be synced.',
validateStatus: 'warning',
})}
>
<Select
mode="tags"
placeholder={`Enter S3 bucket names for ${region}`}
value={bucketsByRegion[region] || []}
onChange={(value): void => handleRegionBucketsChange(region, value)}
tokenSeparators={[',']}
allowClear
disabled={disabled}
suffixIcon={null}
notFoundContent={null}
filterOption={false}
showSearch
/>
</Form.Item>
);
})}
</div>
);
}
S3BucketsSelector.defaultProps = {
onChange: undefined,
initialBucketsByRegion: undefined,
};
export default S3BucketsSelector;

View File

@@ -0,0 +1,162 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw'; // Import RestRequest for req.json() typing
import { UpdateServiceConfigPayload } from '../types';
import { accountsResponse, CLOUD_ACCOUNT_ID, initialBuckets } from './mockData';
import {
assertGenericModalElements,
assertS3SyncSpecificElements,
renderModal,
} from './utils';
// --- MOCKS ---
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => ({
get: jest.fn((paramName: string) => {
if (paramName === 'cloudAccountId') {
return CLOUD_ACCOUNT_ID;
}
return null;
}),
})),
}));
// --- TEST SUITE ---
describe('ConfigureServiceModal for S3 Sync service', () => {
jest.setTimeout(10000);
beforeEach(() => {
server.use(
rest.get(
'http://localhost/api/v1/cloud-integrations/aws/accounts',
(req, res, ctx) => res(ctx.json(accountsResponse)),
),
);
});
it('should render with logs collection switch and bucket selectors (no buckets initially selected)', async () => {
act(() => {
renderModal({}); // No initial S3 buckets, defaults to 's3sync' serviceId
});
await assertGenericModalElements(); // Use new generic assertion
await assertS3SyncSpecificElements({}); // Use new S3-specific assertion
});
it('should render with logs collection switch and bucket selectors (some buckets initially selected)', async () => {
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements(); // Use new generic assertion
await assertS3SyncSpecificElements(initialBuckets); // Use new S3-specific assertion
});
it('should enable save button after adding a new bucket via combobox', async () => {
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements();
await assertS3SyncSpecificElements(initialBuckets);
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
const targetCombobox = screen.getAllByRole('combobox')[0];
const newBucketName = 'a-newly-added-bucket';
act(() => {
fireEvent.change(targetCombobox, { target: { value: newBucketName } });
fireEvent.keyDown(targetCombobox, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
});
});
await waitFor(() => {
expect(screen.getByLabelText(newBucketName)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
});
});
it('should send updated bucket configuration on save', async () => {
let capturedPayload: UpdateServiceConfigPayload | null = null;
const mockUpdateConfigUrl =
'http://localhost/api/v1/cloud-integrations/aws/services/s3sync/config';
// Override POST handler specifically for this test to capture payload
server.use(
rest.post(mockUpdateConfigUrl, async (req: RestRequest, res, ctx) => {
capturedPayload = await req.json();
return res(ctx.status(200), ctx.json({ message: 'Config updated' }));
}),
);
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements();
await assertS3SyncSpecificElements(initialBuckets);
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
const newBucketName = 'another-new-bucket';
// As before, targeting the first combobox, assumed to be for 'ap-south-1'.
const targetCombobox = screen.getAllByRole('combobox')[0];
// eslint-disable-next-line sonarjs/no-identical-functions
act(() => {
fireEvent.change(targetCombobox, { target: { value: newBucketName } });
fireEvent.keyDown(targetCombobox, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
});
});
await waitFor(() => {
expect(screen.getByLabelText(newBucketName)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
act(() => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
});
});
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
});
expect(capturedPayload).toEqual({
cloud_account_id: CLOUD_ACCOUNT_ID,
config: {
logs: {
enabled: true,
s3_buckets: {
'us-east-2': ['first-bucket', 'second-bucket'], // Existing buckets
'ap-south-1': [newBucketName], // Newly added bucket for the first region
},
},
metrics: {},
},
});
});
it('should not render S3 bucket region selector UI for services other than s3sync', async () => {
const otherServiceId = 'cloudwatch';
act(() => {
renderModal({}, otherServiceId);
});
await assertGenericModalElements();
await waitFor(() => {
expect(
screen.queryByRole('heading', { name: /select s3 buckets by region/i }),
).not.toBeInTheDocument();
const regions = accountsResponse.data.accounts[0]?.config?.regions || [];
regions.forEach((region) => {
expect(
screen.queryByText(`Enter S3 bucket names for ${region}`),
).not.toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,44 @@
import { IConfigureServiceModalProps } from '../ConfigureServiceModal';
const CLOUD_ACCOUNT_ID = '123456789012';
const initialBuckets = { 'us-east-2': ['first-bucket', 'second-bucket'] };
const accountsResponse = {
status: 'success',
data: {
accounts: [
{
id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
cloud_account_id: CLOUD_ACCOUNT_ID,
config: {
regions: ['ap-south-1', 'ap-south-2', 'us-east-1', 'us-east-2'],
},
status: {
integration: {
last_heartbeat_ts_ms: 1747114366214,
},
},
},
],
},
};
const defaultModalProps: Omit<IConfigureServiceModalProps, 'initialConfig'> = {
isOpen: true,
onClose: jest.fn(),
serviceName: 'S3 Sync',
serviceId: 's3sync',
cloudAccountId: CLOUD_ACCOUNT_ID,
supportedSignals: {
logs: true,
metrics: false,
},
};
export {
accountsResponse,
CLOUD_ACCOUNT_ID,
defaultModalProps,
initialBuckets,
};

View File

@@ -0,0 +1,79 @@
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import ConfigureServiceModal from '../ConfigureServiceModal';
import { accountsResponse, defaultModalProps } from './mockData';
/**
* Renders the ConfigureServiceModal with specified S3 bucket initial configurations.
*/
const renderModal = (
initialConfigLogsS3Buckets: Record<string, string[]> = {},
serviceId = 's3sync',
): RenderResult => {
const initialConfig = {
logs: { enabled: true, s3_buckets: initialConfigLogsS3Buckets },
metrics: { enabled: false },
};
return render(
<MockQueryClientProvider>
<ConfigureServiceModal
// eslint-disable-next-line react/jsx-props-no-spreading
{...defaultModalProps}
serviceId={serviceId}
initialConfig={initialConfig}
/>
</MockQueryClientProvider>,
);
};
/**
* Asserts that generic UI elements of the modal are present.
*/
const assertGenericModalElements = async (): Promise<void> => {
await waitFor(() => {
expect(screen.getByRole('switch')).toBeInTheDocument();
expect(screen.getByText(/log collection/i)).toBeInTheDocument();
expect(
screen.getByText(
/to ingest logs from your aws services, you must complete several steps/i,
),
).toBeInTheDocument();
});
};
/**
* Asserts the state of S3 bucket selectors for each region, specific to S3 Sync.
*/
const assertS3SyncSpecificElements = async (
expectedBucketsByRegion: Record<string, string[]> = {},
): Promise<void> => {
const regions = accountsResponse.data.accounts[0]?.config?.regions || [];
await waitFor(() => {
expect(
screen.getByRole('heading', { name: /select s3 buckets by region/i }),
).toBeInTheDocument();
regions.forEach((region) => {
expect(screen.getByText(region)).toBeInTheDocument();
const bucketsForRegion = expectedBucketsByRegion[region] || [];
if (bucketsForRegion.length > 0) {
bucketsForRegion.forEach((bucket) => {
expect(screen.getByText(bucket)).toBeInTheDocument();
});
} else {
expect(
screen.getByText(`Enter S3 bucket names for ${region}`),
).toBeInTheDocument();
}
});
});
};
export {
assertGenericModalElements,
assertS3SyncSpecificElements,
renderModal,
};

View File

@@ -34,9 +34,18 @@ interface DataStatus {
last_received_from: string;
}
interface S3BucketsByRegion {
[region: string]: string[];
}
interface LogsConfig extends ConfigStatus {
s3_buckets?: S3BucketsByRegion;
}
interface ServiceConfig {
logs: ConfigStatus;
logs: LogsConfig;
metrics: ConfigStatus;
s3_sync?: LogsConfig;
}
interface IServiceStatus {
@@ -99,6 +108,7 @@ interface UpdateServiceConfigPayload {
config: {
logs: {
enabled: boolean;
s3_buckets?: S3BucketsByRegion;
};
metrics: {
enabled: boolean;
@@ -113,6 +123,7 @@ interface UpdateServiceConfigResponse {
config: {
logs: {
enabled: boolean;
s3_buckets?: S3BucketsByRegion;
};
metrics: {
enabled: boolean;
@@ -125,6 +136,7 @@ export type {
CloudAccount,
CloudAccountsData,
IServiceStatus,
S3BucketsByRegion,
Service,
ServiceConfig,
ServiceData,

View File

@@ -0,0 +1,63 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
IntegrationType,
RequestIntegrationBtn,
} from 'pages/Integrations/RequestIntegrationBtn';
import { I18nextProvider } from 'react-i18next';
import i18n from 'ReactI18';
describe('Request AWS integration', () => {
it('should render the request integration button', async () => {
let capturedPayload: any;
server.use(
rest.post('http://localhost/api/v1/event', async (req, res, ctx) => {
capturedPayload = await req.json();
return res(
ctx.status(200),
ctx.json({
statusCode: 200,
error: null,
payload: 'Event Processed Successfully',
}),
);
}),
);
act(() => {
render(
<I18nextProvider i18n={i18n}>
<RequestIntegrationBtn type={IntegrationType.AWS_SERVICES} />{' '}
</I18nextProvider>,
);
});
expect(
screen.getByText(
/cannot find what youre looking for\? request more integrations/i,
),
).toBeInTheDocument();
await act(() => {
fireEvent.change(screen.getByPlaceholderText(/Enter integration name/i), {
target: { value: 's3 sync' },
});
const submitButton = screen.getByRole('button', { name: /submit/i });
expect(submitButton).toBeEnabled();
fireEvent.click(submitButton);
});
expect(capturedPayload.eventName).toBeDefined();
expect(capturedPayload.attributes).toBeDefined();
expect(capturedPayload.eventName).toBe('AWS service integration requested');
expect(capturedPayload.attributes).toEqual({
screen: 'AWS integration details',
integration: 's3 sync',
tenant_url: 'localhost',
});
});
});

View File

@@ -25,6 +25,51 @@
background: var(--bg-slate-500);
}
.home-container-banner {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 8px 12px;
width: 100%;
background-color: var(--bg-robin-500);
.home-container-banner-close {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--bg-vanilla-100);
position: absolute;
right: 12px;
}
.home-container-banner-content {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px;
.home-container-banner-link {
color: var(--bg-vanilla-100);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
text-decoration: underline;
}
}
}
.home-header-left {
display: flex;
align-items: center;

View File

@@ -10,6 +10,7 @@ import getAllUserPreferences from 'api/preferences/getAllUserPreference';
import updateUserPreferenceAPI from 'api/preferences/updateUserPreference';
import Header from 'components/Header/Header';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
@@ -20,7 +21,7 @@ import { useGetK8sPodsList } from 'hooks/infraMonitoring/useGetK8sPodsList';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import history from 'lib/history';
import cloneDeep from 'lodash-es/cloneDeep';
import { CompassIcon, DotIcon, HomeIcon, Plus, Wrench } from 'lucide-react';
import { CompassIcon, DotIcon, HomeIcon, Plus, Wrench, X } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import * as motion from 'motion/react-client';
import Card from 'periscope/components/Card/Card';
@@ -61,6 +62,13 @@ export default function Home(): JSX.Element {
false,
);
const [isBannerDismissed, setIsBannerDismissed] = useState(false);
useEffect(() => {
const bannerDismissed = localStorage.getItem(LOCALSTORAGE.BANNER_DISMISSED);
setIsBannerDismissed(bannerDismissed === 'true');
}, []);
useEffect(() => {
const now = new Date();
const startTime = new Date(now.getTime() - homeInterval);
@@ -310,9 +318,35 @@ export default function Home(): JSX.Element {
logEvent('Homepage: Visited', {});
}, []);
const hideBanner = (): void => {
localStorage.setItem(LOCALSTORAGE.BANNER_DISMISSED, 'true');
setIsBannerDismissed(true);
};
return (
<div className="home-container">
<div className="sticky-header">
{!isBannerDismissed && (
<div className="home-container-banner">
<div className="home-container-banner-content">
Big news: SigNoz Cloud Teams plan now starting at just $49/Month -
<a
href="https://signoz.io/blog/cloud-teams-plan-now-at-49usd/"
target="_blank"
rel="noreferrer"
className="home-container-banner-link"
>
<i>read more</i>
</a>
🥳🎉
</div>
<div className="home-container-banner-close">
<X size={16} onClick={hideBanner} />
</div>
</div>
)}
<Header
leftComponent={
<div className="home-header-left">

View File

@@ -5,9 +5,9 @@
/* eslint-disable prefer-destructuring */
import { Color } from '@signozhq/design-tokens';
import { Tooltip, Typography } from 'antd';
import Table, { ColumnsType } from 'antd/es/table';
import { Table, Tooltip, Typography } from 'antd';
import { Progress } from 'antd/lib';
import { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';

View File

@@ -1,8 +1,8 @@
import Spinner from 'components/Spinner';
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
import { useGetApDexSettings } from 'hooks/apDex/useGetApDexSettings';
import useErrorNotification from 'hooks/useErrorNotification';
import { memo } from 'react';
import { useNotifications } from 'hooks/useNotifications';
import { memo, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { IServiceName } from '../../types';
@@ -17,11 +17,20 @@ function ApDexApplication({
}: ApDexApplicationProps): JSX.Element {
const { servicename: encodedServiceName } = useParams<IServiceName>();
const servicename = decodeURIComponent(encodedServiceName);
const { notifications } = useNotifications();
const { data, isLoading, error, isRefetching } = useGetApDexSettings(
servicename,
);
useErrorNotification(error);
useEffect(() => {
if (error) {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
}
}, [error, notifications]);
if (isLoading || isRefetching) {
return (

View File

@@ -47,7 +47,7 @@ function GraphControlsPanel({
onClick={onViewAPIMonitoringClick}
style={{ color: Color.BG_VANILLA_100 }}
>
View Third Party API
View External APIs
</Button>
)}
</div>

View File

@@ -15,8 +15,11 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Search } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { COMPOSITE_QUERY_KEY } from './constants';
function MetricNameSearch(): JSX.Element {
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
@@ -24,6 +27,7 @@ function MetricNameSearch(): JSX.Element {
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const [, setSearchParams] = useSearchParams();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [searchString, setSearchString] = useState<string>('');
@@ -66,7 +70,7 @@ function MetricNameSearch(): JSX.Element {
const handleSelect = useCallback(
(selectedMetricName: string): void => {
handleChangeQueryData('filters', {
const newFilter = {
items: [
...currentQuery.builder.queryData[0].filters.items,
{
@@ -81,10 +85,26 @@ function MetricNameSearch(): JSX.Element {
},
],
op: 'AND',
};
const compositeQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: newFilter,
},
],
},
};
handleChangeQueryData('filters', newFilter);
setSearchParams({
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
});
setIsPopoverOpen(false);
},
[currentQuery.builder.queryData, handleChangeQueryData],
[currentQuery, handleChangeQueryData, setSearchParams],
);
const metricNameFilterValues = useMemo(

View File

@@ -4,8 +4,13 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Search } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { METRIC_TYPE_LABEL_MAP, METRIC_TYPE_VALUES_MAP } from './constants';
import {
COMPOSITE_QUERY_KEY,
METRIC_TYPE_LABEL_MAP,
METRIC_TYPE_VALUES_MAP,
} from './constants';
function MetricTypeSearch(): JSX.Element {
const { currentQuery } = useQueryBuilder();
@@ -15,6 +20,7 @@ function MetricTypeSearch(): JSX.Element {
entityVersion: '',
});
const [, setSearchParams] = useSearchParams();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const menuItems = useMemo(
@@ -34,7 +40,7 @@ function MetricTypeSearch(): JSX.Element {
const handleSelect = useCallback(
(selectedMetricType: string): void => {
if (selectedMetricType !== 'all') {
handleChangeQueryData('filters', {
const newFilter = {
items: [
...currentQuery.builder.queryData[0].filters.items,
{
@@ -49,18 +55,50 @@ function MetricTypeSearch(): JSX.Element {
},
],
op: 'AND',
};
const compositeQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: newFilter,
},
],
},
};
handleChangeQueryData('filters', newFilter);
setSearchParams({
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
});
} else {
handleChangeQueryData('filters', {
const newFilter = {
items: currentQuery.builder.queryData[0].filters.items.filter(
(item) => item.id !== 'metric_type',
),
op: 'AND',
};
const compositeQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: newFilter,
},
],
},
};
handleChangeQueryData('filters', newFilter);
setSearchParams({
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
});
}
setIsPopoverOpen(false);
},
[currentQuery.builder.queryData, handleChangeQueryData],
[currentQuery, handleChangeQueryData, setSearchParams],
);
const menu = (

View File

@@ -1,32 +1,13 @@
import { Select, Tooltip } from 'antd';
import { Tooltip } from 'antd';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { HardHat, Info } from 'lucide-react';
import { TREEMAP_VIEW_OPTIONS } from './constants';
import { MetricsSearchProps } from './types';
function MetricsSearch({
query,
onChange,
heatmapView,
setHeatmapView,
}: MetricsSearchProps): JSX.Element {
function MetricsSearch({ query, onChange }: MetricsSearchProps): JSX.Element {
return (
<div className="metrics-search-container">
<div className="metrics-search-options">
<Select
style={{ width: 140 }}
options={TREEMAP_VIEW_OPTIONS}
value={heatmapView}
onChange={setHeatmapView}
/>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
/>
</div>
<div className="qb-search-container">
<Tooltip
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
@@ -41,6 +22,13 @@ function MetricsSearch({
isMetricsExplorer
/>
</div>
<div className="metrics-search-options">
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
/>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { Group } from '@visx/group';
import { Treemap } from '@visx/hierarchy';
import { Empty, Skeleton, Tooltip, Typography } from 'antd';
import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd';
import { stratify, treemapBinary } from 'd3-hierarchy';
import { Info } from 'lucide-react';
import { useMemo } from 'react';
@@ -10,6 +10,7 @@ import {
TREEMAP_HEIGHT,
TREEMAP_MARGINS,
TREEMAP_SQUARE_PADDING,
TREEMAP_VIEW_OPTIONS,
} from './constants';
import { MetricsTreemapProps, TreemapTile, TreemapViewType } from './types';
import {
@@ -24,6 +25,7 @@ function MetricsTreemap({
isLoading,
isError,
openMetricDetails,
setHeatmapView,
}: MetricsTreemapProps): JSX.Element {
const { width: windowWidth } = useWindowSize();
@@ -55,7 +57,10 @@ function MetricsTreemap({
if (isLoading) {
return (
<div data-testid="metrics-treemap-loading-state">
<Skeleton style={{ width: treemapWidth, height: TREEMAP_HEIGHT }} active />
<Skeleton
style={{ width: treemapWidth, height: TREEMAP_HEIGHT + 55 }}
active
/>
</div>
);
}
@@ -90,13 +95,20 @@ function MetricsTreemap({
data-testid="metrics-treemap-container"
>
<div className="metrics-treemap-title">
<Typography.Title level={4}>Proportion View</Typography.Title>
<Tooltip
title="The treemap displays the proportion of samples/timeseries in the selected time range. Each tile represents a unique metric, and its size indicates the percentage of samples/timeseries it contributes to the total."
placement="right"
>
<Info size={16} />
</Tooltip>
<div className="metrics-treemap-title-left">
<Typography.Title level={4}>Proportion View</Typography.Title>
<Tooltip
title="The treemap displays the proportion of samples/timeseries in the selected time range. Each tile represents a unique metric, and its size indicates the percentage of samples/timeseries it contributes to the total."
placement="right"
>
<Info size={16} />
</Tooltip>
</div>
<Select
options={TREEMAP_VIEW_OPTIONS}
value={viewType}
onChange={setHeatmapView}
/>
</div>
<svg
width={treemapWidth}

View File

@@ -21,9 +21,22 @@
}
}
.metrics-treemap-title {
justify-content: space-between;
.metrics-treemap-title-left {
display: flex;
align-items: center;
gap: 8px;
}
.ant-select {
width: 140px;
}
}
.metrics-search-container {
display: flex;
flex-direction: column;
gap: 16px;
.metrics-search-options {
@@ -35,6 +48,7 @@
display: flex;
align-items: center;
gap: 8px;
flex: 1;
.lucide-info {
cursor: pointer;

View File

@@ -11,6 +11,7 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { AppState } from 'store/reducers';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -18,6 +19,12 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import InspectModal from '../Inspect';
import MetricDetails from '../MetricDetails';
import {
COMPOSITE_QUERY_KEY,
IS_INSPECT_MODAL_OPEN_KEY,
IS_METRIC_DETAILS_OPEN_KEY,
SELECTED_METRIC_NAME_KEY,
} from './constants';
import MetricsSearch from './MetricsSearch';
import MetricsTable from './MetricsTable';
import MetricsTreemap from './MetricsTreemap';
@@ -40,10 +47,16 @@ function Summary(): JSX.Element {
const [heatmapView, setHeatmapView] = useState<TreemapViewType>(
TreemapViewType.TIMESERIES,
);
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
const [isInspectModalOpen, setIsInspectModalOpen] = useState(false);
const [selectedMetricName, setSelectedMetricName] = useState<string | null>(
null,
const [searchParams, setSearchParams] = useSearchParams();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(
() => searchParams.get(IS_METRIC_DETAILS_OPEN_KEY) === 'true' || false,
);
const [isInspectModalOpen, setIsInspectModalOpen] = useState(
() => searchParams.get(IS_INSPECT_MODAL_OPEN_KEY) === 'true' || false,
);
const [selectedMetricName, setSelectedMetricName] = useState(
() => searchParams.get(SELECTED_METRIC_NAME_KEY) || null,
);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
@@ -75,13 +88,25 @@ function Summary(): JSX.Element {
useShareBuilderUrl(defaultQuery);
// This is used to avoid the filters from being serialized with the id
const currentQueryFiltersString = useMemo(() => {
const filters = currentQuery?.builder?.queryData[0]?.filters;
if (!filters) return '';
const filtersWithoutId = {
...filters,
items: filters.items.map(({ id, ...rest }) => rest),
};
return JSON.stringify(filtersWithoutId);
}, [currentQuery]);
const queryFilters = useMemo(
() =>
currentQuery?.builder?.queryData[0]?.filters || {
items: [],
op: 'and',
},
[currentQuery],
// eslint-disable-next-line react-hooks/exhaustive-deps
[currentQueryFiltersString],
);
const { handleChangeQueryData } = useQueryOperations({
@@ -145,9 +170,24 @@ function Summary(): JSX.Element {
const handleFilterChange = useCallback(
(value: TagFilter) => {
handleChangeQueryData('filters', value);
const compositeQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: value,
},
],
},
};
setSearchParams({
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
});
setCurrentPage(1);
},
[handleChangeQueryData],
[handleChangeQueryData, currentQuery, setSearchParams],
);
const updatedCurrentQuery = useMemo(
@@ -184,17 +224,29 @@ function Summary(): JSX.Element {
const openMetricDetails = (metricName: string): void => {
setSelectedMetricName(metricName);
setIsMetricDetailsOpen(true);
setSearchParams({
[IS_METRIC_DETAILS_OPEN_KEY]: 'true',
[SELECTED_METRIC_NAME_KEY]: metricName,
});
};
const closeMetricDetails = (): void => {
setSelectedMetricName(null);
setIsMetricDetailsOpen(false);
setSearchParams({
[IS_METRIC_DETAILS_OPEN_KEY]: 'false',
[SELECTED_METRIC_NAME_KEY]: '',
});
};
const openInspectModal = (metricName: string): void => {
setSelectedMetricName(metricName);
setIsInspectModalOpen(true);
setIsMetricDetailsOpen(false);
setSearchParams({
[IS_INSPECT_MODAL_OPEN_KEY]: 'true',
[SELECTED_METRIC_NAME_KEY]: metricName,
});
};
const closeInspectModal = (): void => {
@@ -204,23 +256,23 @@ function Summary(): JSX.Element {
});
setIsInspectModalOpen(false);
setSelectedMetricName(null);
setSearchParams({
[IS_INSPECT_MODAL_OPEN_KEY]: 'false',
[SELECTED_METRIC_NAME_KEY]: '',
});
};
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="metrics-explorer-summary-tab">
<MetricsSearch
query={searchQuery}
onChange={handleFilterChange}
heatmapView={heatmapView}
setHeatmapView={setHeatmapView}
/>
<MetricsSearch query={searchQuery} onChange={handleFilterChange} />
<MetricsTreemap
data={treeMapData?.payload}
isLoading={isTreeMapLoading || isTreeMapFetching}
isError={isProportionViewError}
viewType={heatmapView}
openMetricDetails={openMetricDetails}
setHeatmapView={setHeatmapView}
/>
<MetricsTable
isLoading={isMetricsLoading || isMetricsFetching}

View File

@@ -1,5 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
@@ -28,7 +29,23 @@ const mockData: MetricsListItemRowData[] = [
},
];
jest.mock('react-router-dom-v5-compat', () => {
const actual = jest.requireActual('react-router-dom-v5-compat');
return {
...actual,
useSearchParams: jest.fn().mockReturnValue([{}, jest.fn()]),
useNavigationType: (): any => 'PUSH',
};
});
describe('MetricsTable', () => {
beforeEach(() => {
jest
.spyOn(useQueryBuilderOperationsHooks, 'useQueryOperations')
.mockReturnValue({
handleChangeQueryData: jest.fn(),
} as any);
});
jest
.spyOn(useGetMetricsListFilterValues, 'useGetMetricsListFilterValues')
.mockReturnValue({

View File

@@ -55,6 +55,7 @@ describe('MetricsTreemap', () => {
}}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@@ -79,6 +80,7 @@ describe('MetricsTreemap', () => {
}}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@@ -105,6 +107,7 @@ describe('MetricsTreemap', () => {
}}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@@ -128,6 +131,7 @@ describe('MetricsTreemap', () => {
data={null}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>
</MemoryRouter>,

View File

@@ -0,0 +1,150 @@
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import ROUTES from 'constants/routes';
import * as useGetMetricsListHooks from 'hooks/metricsExplorer/useGetMetricsList';
import * as useGetMetricsTreeMapHooks from 'hooks/metricsExplorer/useGetMetricsTreeMap';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import store from 'store';
import Summary from '../Summary';
import { TreemapViewType } from '../types';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('d3-hierarchy', () => ({
stratify: jest.fn().mockReturnValue({
id: jest.fn().mockReturnValue({
parentId: jest.fn().mockReturnValue(
jest.fn().mockReturnValue({
sum: jest.fn().mockReturnValue({
descendants: jest.fn().mockReturnValue([]),
eachBefore: jest.fn().mockReturnValue([]),
}),
}),
),
}),
}),
treemapBinary: jest.fn(),
}));
jest.mock('react-use', () => ({
useWindowSize: jest.fn().mockReturnValue({ width: 1000, height: 1000 }),
}));
jest.mock('react-router-dom-v5-compat', () => {
const actual = jest.requireActual('react-router-dom-v5-compat');
return {
...actual,
useSearchParams: jest.fn(),
useNavigationType: (): any => 'PUSH',
};
});
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.METRICS_EXPLORER_BASE}`,
}),
}));
const queryClient = new QueryClient();
const mockMetricName = 'test-metric';
jest.spyOn(useGetMetricsListHooks, 'useGetMetricsList').mockReturnValue({
data: {
payload: {
status: 'success',
data: {
metrics: [
{
metric_name: mockMetricName,
description: 'description for a test metric',
type: MetricType.GAUGE,
unit: 'count',
lastReceived: '1715702400',
[TreemapViewType.TIMESERIES]: 100,
[TreemapViewType.SAMPLES]: 100,
},
],
},
},
},
isError: false,
isLoading: false,
} as any);
jest.spyOn(useGetMetricsTreeMapHooks, 'useGetMetricsTreeMap').mockReturnValue({
data: {
payload: {
status: 'success',
data: {
[TreemapViewType.TIMESERIES]: [
{
metric_name: mockMetricName,
percentage: 100,
total_value: 100,
},
],
[TreemapViewType.SAMPLES]: [
{
metric_name: mockMetricName,
percentage: 100,
},
],
},
},
},
isError: false,
isLoading: false,
} as any);
const mockSetSearchParams = jest.fn();
describe('Summary', () => {
it('persists inspect modal open state across page refresh', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({
isInspectModalOpen: 'true',
selectedMetricName: 'test-metric',
}),
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Summary />
</Provider>
</QueryClientProvider>,
);
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
});
it('persists metric details modal state across page refresh', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({
isMetricDetailsOpen: 'true',
selectedMetricName: mockMetricName,
}),
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Summary />
</Provider>
</QueryClientProvider>,
);
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
});
});

View File

@@ -32,3 +32,8 @@ export const METRIC_TYPE_VALUES_MAP = {
[MetricType.SUMMARY]: 'Summary',
[MetricType.EXPONENTIAL_HISTOGRAM]: 'ExponentialHistogram',
};
export const IS_METRIC_DETAILS_OPEN_KEY = 'isMetricDetailsOpen';
export const IS_INSPECT_MODAL_OPEN_KEY = 'isInspectModalOpen';
export const SELECTED_METRIC_NAME_KEY = 'selectedMetricName';
export const COMPOSITE_QUERY_KEY = 'compositeQuery';

View File

@@ -20,8 +20,6 @@ export interface MetricsTableProps {
export interface MetricsSearchProps {
query: IBuilderQuery;
onChange: (value: TagFilter) => void;
heatmapView: TreemapViewType;
setHeatmapView: (value: TreemapViewType) => void;
}
export interface MetricsTreemapProps {
@@ -30,6 +28,7 @@ export interface MetricsTreemapProps {
isError: boolean;
viewType: TreemapViewType;
openMetricDetails: (metricName: string) => void;
setHeatmapView: (value: TreemapViewType) => void;
}
export interface OrderByPayload {

View File

@@ -0,0 +1,474 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import {
IDashboardVariable,
TSortVariableValuesType,
VariableSortTypeArr,
} from 'types/api/dashboard/getAll';
import VariableItem from './VariableItem';
// Mock modules
jest.mock('api/dashboard/variables/dashboardVariablesQuery', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue({
payload: {
variableValues: ['value1', 'value2', 'value3'],
},
}),
}));
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('test-uuid'),
}));
// Mock functions
const onCancel = jest.fn();
const onSave = jest.fn();
const validateName = jest.fn(() => true);
// Mode constant
const VARIABLE_MODE = 'ADD';
// Common text constants
const TEXT = {
INCLUDE_ALL_VALUES: 'Include an option for ALL values',
ENABLE_MULTI_VALUES: 'Enable multiple values to be checked',
VARIABLE_EXISTS: 'Variable name already exists',
SORT_VALUES: 'Sort Values',
DEFAULT_VALUE: 'Default Value',
ALL_VARIABLES: 'All variables',
DISCARD: 'Discard',
OPTIONS: 'Options',
QUERY: 'Query',
TEXTBOX: 'Textbox',
CUSTOM: 'Custom',
};
// Common test constants
const VARIABLE_DEFAULTS = {
sort: VariableSortTypeArr[0] as TSortVariableValuesType,
multiSelect: false,
showALLOption: false,
};
// Common variable properties
const TEST_VAR_NAMES = {
VAR1: 'variable1',
VAR2: 'variable2',
VAR3: 'variable3',
};
const TEST_VAR_IDS = {
VAR1: 'var1',
VAR2: 'var2',
VAR3: 'var3',
};
const TEST_VAR_DESCRIPTIONS = {
VAR1: 'Variable 1',
VAR2: 'Variable 2',
VAR3: 'Variable 3',
};
// Common UI elements
const SAVE_BUTTON_TEXT = 'Save Variable';
const UNIQUE_NAME_PLACEHOLDER = 'Unique name of the variable';
// Create QueryClient for wrapping the component
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Wrapper component with QueryClientProvider
const wrapper = ({ children }: { children: React.ReactNode }): JSX.Element => (
<QueryClientProvider client={createTestQueryClient()}>
{children}
</QueryClientProvider>
);
// Basic variable data for testing
const basicVariableData: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Variable 1',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
...VARIABLE_DEFAULTS,
order: 0,
};
// Helper function to render VariableItem with common props
const renderVariableItem = (
variableData: IDashboardVariable = basicVariableData,
existingVariables: Record<string, IDashboardVariable> = {},
validateNameFn = validateName,
): void => {
render(
<VariableItem
variableData={variableData}
existingVariables={existingVariables}
onCancel={onCancel}
onSave={onSave}
validateName={validateNameFn}
mode={VARIABLE_MODE}
/>,
{ wrapper } as any,
);
};
// Helper function to find button by text within its span
const findButtonByText = (text: string): HTMLElement | null => {
const buttons = screen.getAllByRole('button');
return buttons.find((button) => button.textContent?.includes(text)) || null;
};
describe('VariableItem Component', () => {
// Test SQL query patterns
const SQL_PATTERN_DOT = 'SELECT * FROM test WHERE env = {{.variable2}}';
const SQL_PATTERN_DOLLAR = 'SELECT * FROM test WHERE env = $variable2';
const SQL_PATTERN_BRACKET = 'SELECT * FROM test WHERE service = [[variable3]]';
const SQL_PATTERN_BRACES = 'SELECT * FROM test WHERE app = {{variable1}}';
const SQL_PATTERN_NO_VARS = 'SELECT * FROM test WHERE env = "prod"';
const SQL_PATTERN_DOT_VAR1 =
'SELECT * FROM test WHERE service = {{.variable1}}';
// Error message text constant
const CIRCULAR_DEPENDENCY_ERROR = /Cannot save: Circular dependency detected/;
// Test functions and utilities
const createVariable = (
id: string,
name: string,
description: string,
queryValue: string,
order: number,
): IDashboardVariable => ({
id,
name,
description,
type: 'QUERY',
queryValue,
...VARIABLE_DEFAULTS,
order,
});
beforeEach(() => {
jest.clearAllMocks();
});
test('renders without crashing', () => {
renderVariableItem();
expect(screen.getByText(TEXT.ALL_VARIABLES)).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Variable Type')).toBeInTheDocument();
});
describe('Variable Name Validation', () => {
test('shows error when variable name already exists', () => {
// Set validateName to return false (name exists)
const mockValidateName = jest.fn().mockReturnValue(false);
renderVariableItem({ ...basicVariableData, name: '' }, {}, mockValidateName);
// Enter a name that already exists
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'existingVariable' } });
// Error message should be displayed
expect(screen.getByText(TEXT.VARIABLE_EXISTS)).toBeInTheDocument();
// We won't check for button disabled state as it might be inconsistent in tests
});
test('allows save when current variable name is used', () => {
// Mock validate to return false for all other names but true for own name
const mockValidateName = jest
.fn()
.mockImplementation((name) => name === TEST_VAR_NAMES.VAR1);
renderVariableItem(basicVariableData, {}, mockValidateName);
// Enter the current variable name
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: TEST_VAR_NAMES.VAR1 } });
// Error should not be visible
expect(screen.queryByText(TEXT.VARIABLE_EXISTS)).not.toBeInTheDocument();
});
});
describe('Variable Type Switching', () => {
test('switches to CUSTOM variable type correctly', () => {
renderVariableItem();
// Find the Query button
const queryButton = findButtonByText(TEXT.QUERY);
expect(queryButton).toBeInTheDocument();
expect(queryButton).toHaveClass('selected');
// Find and click Custom button
const customButton = findButtonByText(TEXT.CUSTOM);
expect(customButton).toBeInTheDocument();
if (customButton) {
fireEvent.click(customButton);
}
// Custom button should now be selected
expect(customButton).toHaveClass('selected');
expect(queryButton).not.toHaveClass('selected');
// Custom options input should appear
expect(screen.getByText(TEXT.OPTIONS)).toBeInTheDocument();
});
test('switches to TEXTBOX variable type correctly', () => {
renderVariableItem();
// Find and click Textbox button
const textboxButton = findButtonByText(TEXT.TEXTBOX);
expect(textboxButton).toBeInTheDocument();
if (textboxButton) {
fireEvent.click(textboxButton);
}
// Textbox button should now be selected
expect(textboxButton).toHaveClass('selected');
// Default Value input should appear
expect(screen.getByText(TEXT.DEFAULT_VALUE)).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Enter a default value (if any)...'),
).toBeInTheDocument();
});
});
describe('MultiSelect and ALL Option', () => {
test('enables ALL option only when multiSelect is enabled', async () => {
renderVariableItem();
// Initially, ALL option should not be visible
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
// Enable multiple values
const multipleValuesSwitch = screen
.getByText(TEXT.ENABLE_MULTI_VALUES)
.closest('.multiple-values-section')
?.querySelector('button');
expect(multipleValuesSwitch).toBeInTheDocument();
if (multipleValuesSwitch) {
fireEvent.click(multipleValuesSwitch);
}
// Now ALL option should be visible
await waitFor(() => {
expect(screen.getByText(TEXT.INCLUDE_ALL_VALUES)).toBeInTheDocument();
});
// Disable multiple values
if (multipleValuesSwitch) {
fireEvent.click(multipleValuesSwitch);
}
// ALL option should be hidden again
await waitFor(() => {
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
});
});
test('disables ALL option when multiSelect is disabled', async () => {
// Create variable with multiSelect and showALLOption both enabled
const variable: IDashboardVariable = {
...basicVariableData,
multiSelect: true,
showALLOption: true,
};
renderVariableItem(variable);
// ALL option should be visible initially
expect(screen.getByText(TEXT.INCLUDE_ALL_VALUES)).toBeInTheDocument();
// Disable multiple values
const multipleValuesSwitch = screen
.getByText(TEXT.ENABLE_MULTI_VALUES)
.closest('.multiple-values-section')
?.querySelector('button');
if (multipleValuesSwitch) {
fireEvent.click(multipleValuesSwitch);
}
// ALL option should be hidden
await waitFor(() => {
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
});
// Check that when saving, showALLOption is set to false
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
fireEvent.click(saveButton);
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
multiSelect: false,
showALLOption: false,
}),
);
});
});
describe('Cancel and Navigation', () => {
test('calls onCancel when clicking All Variables button', () => {
renderVariableItem();
// Click All variables button
const allVariablesButton = screen.getByText(TEXT.ALL_VARIABLES);
fireEvent.click(allVariablesButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
test('calls onCancel when clicking Discard button', () => {
renderVariableItem();
// Click Discard button
const discardButton = screen.getByText(TEXT.DISCARD);
fireEvent.click(discardButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
});
describe('Cyclic Dependency Detection', () => {
// Common function to render the component with variables and click save
const renderAndSave = async (
variableData: IDashboardVariable,
existingVariables: Record<string, IDashboardVariable>,
): Promise<void> => {
renderVariableItem(variableData, existingVariables);
// Fill in the variable name if it's not already populated
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
if (nameInput.getAttribute('value') === '') {
fireEvent.change(nameInput, { target: { value: variableData.name || '' } });
}
// Click save button to trigger the dependency check
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
fireEvent.click(saveButton);
};
// Common expectations for finding circular dependency error
const expectCircularDependencyError = async (): Promise<void> => {
await waitFor(() => {
expect(screen.getByText(CIRCULAR_DEPENDENCY_ERROR)).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
};
// Test for cyclic dependency detection
test('detects circular dependency and shows error message', async () => {
// Create variables with circular dependency
const variable1 = createVariable(
TEST_VAR_IDS.VAR1,
TEST_VAR_NAMES.VAR1,
TEST_VAR_DESCRIPTIONS.VAR1,
SQL_PATTERN_DOT,
0,
);
const variable2 = createVariable(
TEST_VAR_IDS.VAR2,
TEST_VAR_NAMES.VAR2,
TEST_VAR_DESCRIPTIONS.VAR2,
SQL_PATTERN_DOT_VAR1,
1,
);
const existingVariables = {
[TEST_VAR_IDS.VAR2]: variable2,
};
await renderAndSave(variable1, existingVariables);
await expectCircularDependencyError();
});
// Test for saving with no circular dependency
test('allows saving when no circular dependency exists', async () => {
// Create variables without circular dependency
const variable1 = createVariable(
TEST_VAR_IDS.VAR1,
TEST_VAR_NAMES.VAR1,
TEST_VAR_DESCRIPTIONS.VAR1,
SQL_PATTERN_NO_VARS,
0,
);
const variable2 = createVariable(
TEST_VAR_IDS.VAR2,
TEST_VAR_NAMES.VAR2,
TEST_VAR_DESCRIPTIONS.VAR2,
SQL_PATTERN_DOT_VAR1,
1,
);
const existingVariables = {
[TEST_VAR_IDS.VAR2]: variable2,
};
await renderAndSave(variable1, existingVariables);
// Verify the onSave function was called
await waitFor(() => {
expect(onSave).toHaveBeenCalled();
});
});
// Test with multiple variable formats in query
test('detects circular dependency with different variable formats', async () => {
// Create variables with circular dependency using different formats
const variable1 = createVariable(
TEST_VAR_IDS.VAR1,
TEST_VAR_NAMES.VAR1,
TEST_VAR_DESCRIPTIONS.VAR1,
SQL_PATTERN_DOLLAR,
0,
);
const variable2 = createVariable(
TEST_VAR_IDS.VAR2,
TEST_VAR_NAMES.VAR2,
TEST_VAR_DESCRIPTIONS.VAR2,
SQL_PATTERN_BRACKET,
1,
);
const variable3 = createVariable(
TEST_VAR_IDS.VAR3,
TEST_VAR_NAMES.VAR3,
TEST_VAR_DESCRIPTIONS.VAR3,
SQL_PATTERN_BRACES,
2,
);
const existingVariables = {
[TEST_VAR_IDS.VAR2]: variable2,
[TEST_VAR_IDS.VAR3]: variable3,
};
await renderAndSave(variable1, existingVariables);
await expectCircularDependencyError();
});
});
});

View File

@@ -28,6 +28,10 @@ import {
} from 'types/api/dashboard/getAll';
import { v4 as generateUUID } from 'uuid';
import {
buildDependencies,
buildDependencyGraph,
} from '../../../DashboardVariablesSelection/util';
import { variablePropsToPayloadVariables } from '../../../utils';
import { TVariableMode } from '../types';
import { LabelContainer, VariableItemRow } from './styles';
@@ -107,7 +111,8 @@ function VariableItem({
]);
const handleSave = (): void => {
const variable: IDashboardVariable = {
// Check for cyclic dependencies
const newVariable = {
name: variableName,
description: variableDescription,
type: queryType,
@@ -126,7 +131,21 @@ function VariableItem({
order: variableData.order,
};
onSave(mode, variable);
const allVariables = [...Object.values(existingVariables), newVariable];
const dependencies = buildDependencies(allVariables);
const { hasCycle, cycleNodes } = buildDependencyGraph(dependencies);
if (hasCycle) {
setErrorPreview(
`Cannot save: Circular dependency detected between variables: ${cycleNodes?.join(
' → ',
)}`,
);
return;
}
onSave(mode, newVariable);
};
// Fetches the preview values for the SQL variable query

View File

@@ -106,3 +106,9 @@
}
}
}
.cycle-error-alert {
margin-bottom: 12px;
padding: 4px 12px;
font-size: 12px;
}

View File

@@ -1,4 +1,6 @@
import { Row } from 'antd';
import './DashboardVariableSelection.styles.scss';
import { Alert, Row } from 'antd';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { memo, useEffect, useState } from 'react';
@@ -64,7 +66,7 @@ function DashboardVariableSelection(): JSX.Element | null {
useEffect(() => {
if (variablesTableData.length > 0) {
const depGrp = buildDependencies(variablesTableData);
const { order, graph } = buildDependencyGraph(depGrp);
const { order, graph, hasCycle, cycleNodes } = buildDependencyGraph(depGrp);
const parentDependencyGraph = buildParentDependencyGraph(graph);
// cleanup order to only include variables that are of type 'QUERY'
@@ -79,6 +81,8 @@ function DashboardVariableSelection(): JSX.Element | null {
order: cleanedOrder,
graph,
parentDependencyGraph,
hasCycle,
cycleNodes,
});
}
}, [setVariablesToGetUpdated, variables, variablesTableData]);
@@ -166,25 +170,37 @@ function DashboardVariableSelection(): JSX.Element | null {
);
return (
<Row style={{ display: 'flex', gap: '12px' }}>
{orderBasedSortedVariables &&
Array.isArray(orderBasedSortedVariables) &&
orderBasedSortedVariables.length > 0 &&
orderBasedSortedVariables.map((variable) => (
<VariableItem
key={`${variable.name}${variable.id}}${variable.order}`}
existingVariables={variables}
variableData={{
name: variable.name,
...variable,
}}
onValueUpdate={onValueUpdate}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
))}
</Row>
<>
{dependencyData?.hasCycle && (
<Alert
message={`Circular dependency detected: ${dependencyData?.cycleNodes?.join(
' → ',
)}`}
type="error"
showIcon
className="cycle-error-alert"
/>
)}
<Row style={{ display: 'flex', gap: '12px' }}>
{orderBasedSortedVariables &&
Array.isArray(orderBasedSortedVariables) &&
orderBasedSortedVariables.length > 0 &&
orderBasedSortedVariables.map((variable) => (
<VariableItem
key={`${variable.name}${variable.id}}${variable.order}`}
existingVariables={variables}
variableData={{
name: variable.name,
...variable,
}}
onValueUpdate={onValueUpdate}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
))}
</Row>
</>
);
}

View File

@@ -53,6 +53,7 @@ describe('VariableItem', () => {
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
@@ -74,6 +75,7 @@ describe('VariableItem', () => {
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
@@ -94,6 +96,7 @@ describe('VariableItem', () => {
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
@@ -128,6 +131,7 @@ describe('VariableItem', () => {
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
@@ -157,6 +161,7 @@ describe('VariableItem', () => {
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
@@ -178,6 +183,7 @@ describe('VariableItem', () => {
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,

View File

@@ -191,16 +191,6 @@ describe('dashboardVariables - utilities and processors', () => {
describe('buildDependencyGraph', () => {
it('should build complete dependency graph with correct structure and order', () => {
const expected = {
graph: {
deployment_environment: ['service_name', 'endpoint'],
service_name: ['endpoint'],
endpoint: ['http_status_code'],
http_status_code: [],
k8s_cluster_name: ['k8s_node_name', 'k8s_namespace_name'],
k8s_node_name: ['k8s_namespace_name'],
k8s_namespace_name: [],
environment: [],
},
order: [
'deployment_environment',
'k8s_cluster_name',
@@ -211,6 +201,28 @@ describe('dashboardVariables - utilities and processors', () => {
'k8s_namespace_name',
'http_status_code',
],
graph: {
deployment_environment: ['service_name', 'endpoint'],
service_name: ['endpoint'],
endpoint: ['http_status_code'],
http_status_code: [],
k8s_cluster_name: ['k8s_node_name', 'k8s_namespace_name'],
k8s_node_name: ['k8s_namespace_name'],
k8s_namespace_name: [],
environment: [],
},
parentDependencyGraph: {
deployment_environment: [],
service_name: ['deployment_environment'],
endpoint: ['deployment_environment', 'service_name'],
http_status_code: ['endpoint'],
k8s_cluster_name: [],
k8s_node_name: ['k8s_cluster_name'],
k8s_namespace_name: ['k8s_cluster_name', 'k8s_node_name'],
environment: [],
},
hasCycle: false,
cycleNodes: undefined,
};
expect(buildDependencyGraph(graph)).toEqual(expected);

View File

@@ -95,10 +95,96 @@ export const buildDependencies = (
return graph;
};
// Function to build the dependency graph
export interface IDependencyData {
order: string[];
graph: VariableGraph;
parentDependencyGraph: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
export const buildParentDependencyGraph = (
graph: VariableGraph,
): VariableGraph => {
const parentGraph: VariableGraph = {};
// Initialize empty arrays for all nodes
Object.keys(graph).forEach((node) => {
parentGraph[node] = [];
});
// For each node and its children in the original graph
Object.entries(graph).forEach(([node, children]) => {
// For each child, add the current node as its parent
children.forEach((child) => {
parentGraph[child].push(node);
});
});
return parentGraph;
};
const collectCyclePath = (
graph: VariableGraph,
start: string,
end: string,
): string[] => {
const path: string[] = [];
let current = start;
const findParent = (node: string): string | undefined =>
Object.keys(graph).find((key) => graph[key]?.includes(node));
while (current !== end) {
const parent = findParent(current);
if (!parent) break;
path.push(parent);
current = parent;
}
return [start, ...path];
};
const detectCycle = (
graph: VariableGraph,
node: string,
visited: Set<string>,
recStack: Set<string>,
): string[] | null => {
if (!visited.has(node)) {
visited.add(node);
recStack.add(node);
const neighbors = graph[node] || [];
let cycleNodes: string[] | null = null;
neighbors.some((neighbor) => {
if (!visited.has(neighbor)) {
const foundCycle = detectCycle(graph, neighbor, visited, recStack);
if (foundCycle) {
cycleNodes = foundCycle;
return true;
}
} else if (recStack.has(neighbor)) {
// Found a cycle, collect the cycle nodes
cycleNodes = collectCyclePath(graph, node, neighbor);
return true;
}
return false;
});
if (cycleNodes) {
return cycleNodes;
}
}
recStack.delete(node);
return null;
};
export const buildDependencyGraph = (
dependencies: VariableGraph,
): { order: string[]; graph: VariableGraph } => {
// eslint-disable-next-line sonarjs/cognitive-complexity
): IDependencyData => {
const inDegree: Record<string, number> = {};
const adjList: VariableGraph = {};
@@ -113,6 +199,22 @@ export const buildDependencyGraph = (
});
});
// Detect cycles
const visited = new Set<string>();
const recStack = new Set<string>();
let cycleNodes: string[] | undefined;
Object.keys(dependencies).some((node) => {
if (!visited.has(node)) {
const foundCycle = detectCycle(dependencies, node, visited, recStack);
if (foundCycle) {
cycleNodes = foundCycle;
return true;
}
}
return false;
});
// Topological sort using Kahn's Algorithm
const queue: string[] = Object.keys(inDegree).filter(
(node) => inDegree[node] === 0,
@@ -132,11 +234,15 @@ export const buildDependencyGraph = (
});
}
if (topologicalOrder.length !== Object.keys(dependencies)?.length) {
console.error('Cycle detected in the dependency graph!');
}
const hasCycle = topologicalOrder.length !== Object.keys(dependencies)?.length;
return { order: topologicalOrder, graph: adjList };
return {
order: topologicalOrder,
graph: adjList,
parentDependencyGraph: buildParentDependencyGraph(adjList),
hasCycle,
cycleNodes,
};
};
export const onUpdateVariableNode = (
@@ -159,27 +265,6 @@ export const onUpdateVariableNode = (
});
};
export const buildParentDependencyGraph = (
graph: VariableGraph,
): VariableGraph => {
const parentGraph: VariableGraph = {};
// Initialize empty arrays for all nodes
Object.keys(graph).forEach((node) => {
parentGraph[node] = [];
});
// For each node and its children in the original graph
Object.entries(graph).forEach(([node, children]) => {
// For each child, add the current node as its parent
children.forEach((child) => {
parentGraph[child].push(node);
});
});
return parentGraph;
};
export const checkAPIInvocation = (
variablesToGetUpdated: string[],
variableData: IDashboardVariable,
@@ -206,9 +291,3 @@ export const checkAPIInvocation = (
variablesToGetUpdated[0] === variableData.name
);
};
export interface IDependencyData {
order: string[];
graph: VariableGraph;
parentDependencyGraph: VariableGraph;
}

View File

@@ -125,7 +125,7 @@ const menuItems: SidebarItem[] = [
},
{
key: ROUTES.API_MONITORING,
label: 'Third Party API',
label: 'External APIs',
icon: <Binoculars size={16} />,
isNew: true,
},

View File

@@ -43,8 +43,7 @@ export default function useFunnelConfiguration({
const {
steps,
initialSteps,
setHasIncompleteStepFields,
setHasAllEmptyStepFields,
hasIncompleteStepFields,
handleRestoreSteps,
} = useFunnelContext();
@@ -74,14 +73,16 @@ export default function useFunnelConfiguration({
return !isEqual(normalizedDebouncedSteps, normalizedLastSavedSteps);
}, [debouncedSteps]);
const hasStepServiceOrSpanNameChanged = useCallback(
const hasFunnelStepDefinitionsChanged = useCallback(
(prevSteps: FunnelStepData[], nextSteps: FunnelStepData[]): boolean => {
if (prevSteps.length !== nextSteps.length) return true;
return prevSteps.some((step, index) => {
const nextStep = nextSteps[index];
return (
step.service_name !== nextStep.service_name ||
step.span_name !== nextStep.span_name
step.span_name !== nextStep.span_name ||
!isEqual(step.filters, nextStep.filters) ||
step.has_errors !== nextStep.has_errors
);
});
},
@@ -106,12 +107,7 @@ export default function useFunnelConfiguration({
[funnel.funnel_id, selectedTime],
);
useEffect(() => {
// Check if all steps have both service_name and span_name defined
const shouldUpdate = debouncedSteps.every(
(step) => step.service_name !== '' && step.span_name !== '',
);
if (hasStepsChanged() && shouldUpdate) {
if (hasStepsChanged() && !hasIncompleteStepFields) {
updateStepsMutation.mutate(getUpdatePayload(), {
onSuccess: (data) => {
const updatedFunnelSteps = data?.payload?.steps;
@@ -135,17 +131,10 @@ export default function useFunnelConfiguration({
(step) => step.service_name === '' || step.span_name === '',
);
const hasAllEmptyStepsData = updatedFunnelSteps.every(
(step) => step.service_name === '' && step.span_name === '',
);
setHasIncompleteStepFields(hasIncompleteStepFields);
setHasAllEmptyStepFields(hasAllEmptyStepsData);
// Only validate if service_name or span_name changed
if (
!hasIncompleteStepFields &&
hasStepServiceOrSpanNameChanged(lastValidatedSteps, debouncedSteps)
hasFunnelStepDefinitionsChanged(lastValidatedSteps, debouncedSteps)
) {
queryClient.refetchQueries(validateStepsQueryKey);
setLastValidatedSteps(debouncedSteps);
@@ -171,7 +160,7 @@ export default function useFunnelConfiguration({
}, [
debouncedSteps,
getUpdatePayload,
hasStepServiceOrSpanNameChanged,
hasFunnelStepDefinitionsChanged,
hasStepsChanged,
lastValidatedSteps,
queryClient,

View File

@@ -2,8 +2,9 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { MetricItem } from 'pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo } from 'react';
import { LatencyOptions } from 'types/api/traceFunnels';
import { useFunnelOverview } from './useFunnels';
import { useFunnelOverview, useFunnelStepsOverview } from './useFunnels';
interface FunnelMetricsParams {
funnelId: string;
@@ -13,8 +14,6 @@ interface FunnelMetricsParams {
export function useFunnelMetrics({
funnelId,
stepStart,
stepEnd,
}: FunnelMetricsParams): {
isLoading: boolean;
isError: boolean;
@@ -25,8 +24,6 @@ export function useFunnelMetrics({
const payload = {
start_time: startTime,
end_time: endTime,
...(stepStart !== undefined && { step_start: stepStart }),
...(stepEnd !== undefined && { step_end: stepEnd }),
};
const {
@@ -48,14 +45,18 @@ export function useFunnelMetrics({
{ title: 'Errors', value: sourceData.errors },
{
title: 'Avg. Duration',
value: getYAxisFormattedValue(sourceData.avg_duration.toString(), 'ns'),
value: getYAxisFormattedValue(sourceData.avg_duration.toString(), 'ms'),
},
{
title: 'P99 Latency',
value: getYAxisFormattedValue(sourceData.p99_latency.toString(), 'ns'),
title: `P99 Latency`,
value: getYAxisFormattedValue(
// TODO(shaheer): remove p99_latency once we have support for latency
(sourceData.latency ?? sourceData.p99_latency).toString(),
'ms',
),
},
];
}, [overviewData]);
}, [overviewData?.payload?.data]);
const conversionRate =
overviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
@@ -67,3 +68,72 @@ export function useFunnelMetrics({
conversionRate,
};
}
export function useFunnelStepsMetrics({
funnelId,
stepStart,
stepEnd,
}: FunnelMetricsParams): {
isLoading: boolean;
isError: boolean;
metricsData: MetricItem[];
conversionRate: number;
} {
const { startTime, endTime, steps } = useFunnelContext();
const payload = {
start_time: startTime,
end_time: endTime,
step_start: stepStart,
step_end: stepEnd,
};
const {
data: stepsOverviewData,
isLoading,
isFetching,
isError,
} = useFunnelStepsOverview(funnelId, payload);
const latencyType = useMemo(
() => (stepStart ? steps[stepStart]?.latency_type : LatencyOptions.P99),
[stepStart, steps],
);
const metricsData = useMemo(() => {
const sourceData = stepsOverviewData?.payload?.data?.[0]?.data;
if (!sourceData) return [];
return [
{
title: 'Avg. Rate',
value: `${Number(sourceData.avg_rate.toFixed(2))} req/s`,
},
{ title: 'Errors', value: sourceData.errors },
{
title: 'Avg. Duration',
value: getYAxisFormattedValue(
(sourceData.avg_duration * 1_000_000).toString(),
'ns',
),
},
{
title: `${latencyType?.toUpperCase()} Latency`,
value: getYAxisFormattedValue(
// TODO(shaheer): remove p99_latency once we have support for latency
((sourceData.latency ?? sourceData.p99_latency) * 1_000_000).toString(),
'ns',
),
},
];
}, [stepsOverviewData, latencyType]);
const conversionRate =
stepsOverviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
return {
isLoading: isLoading || isFetching,
isError,
metricsData,
conversionRate,
};
}

View File

@@ -3,9 +3,10 @@ import {
createFunnel,
deleteFunnel,
ErrorTraceData,
ErrorTracesPayload,
FunnelOverviewPayload,
FunnelOverviewResponse,
FunnelStepsOverviewPayload,
FunnelStepsOverviewResponse,
FunnelStepsResponse,
getFunnelById,
getFunnelErrorTraces,
@@ -13,11 +14,11 @@ import {
getFunnelsList,
getFunnelSlowTraces,
getFunnelSteps,
getFunnelStepsOverview,
renameFunnel,
RenameFunnelPayload,
saveFunnelDescription,
SlowTraceData,
SlowTracesPayload,
updateFunnelSteps,
UpdateFunnelStepsPayload,
ValidateFunnelResponse,
@@ -115,11 +116,13 @@ export const useValidateFunnelSteps = ({
selectedTime,
startTime,
endTime,
enabled,
}: {
funnelId: string;
selectedTime: string;
startTime: number;
endTime: number;
enabled: boolean;
}): UseQueryResult<
SuccessResponse<ValidateFunnelResponse> | ErrorResponse,
Error
@@ -132,8 +135,8 @@ export const useValidateFunnelSteps = ({
signal,
),
queryKey: [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime],
enabled: !!funnelId && !!selectedTime && !!startTime && !!endTime,
staleTime: 1000 * 60 * 5,
enabled,
staleTime: 0,
});
interface SaveFunnelDescriptionPayload {
@@ -157,7 +160,11 @@ export const useFunnelOverview = (
SuccessResponse<FunnelOverviewResponse> | ErrorResponse,
Error
> => {
const { selectedTime, validTracesCount } = useFunnelContext();
const {
selectedTime,
validTracesCount,
hasFunnelBeenExecuted,
} = useFunnelContext();
return useQuery({
queryFn: ({ signal }) => getFunnelOverview(funnelId, payload, signal),
queryKey: [
@@ -167,31 +174,51 @@ export const useFunnelOverview = (
payload.step_start ?? '',
payload.step_end ?? '',
],
enabled: !!funnelId && validTracesCount > 0,
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
});
};
export const useFunnelSlowTraces = (
funnelId: string,
payload: SlowTracesPayload,
payload: FunnelOverviewPayload,
): UseQueryResult<SuccessResponse<SlowTraceData> | ErrorResponse, Error> => {
const { selectedTime, validTracesCount } = useFunnelContext();
const {
selectedTime,
validTracesCount,
hasFunnelBeenExecuted,
} = useFunnelContext();
return useQuery<SuccessResponse<SlowTraceData> | ErrorResponse, Error>({
queryFn: ({ signal }) => getFunnelSlowTraces(funnelId, payload, signal),
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES, funnelId, selectedTime],
enabled: !!funnelId && validTracesCount > 0,
queryKey: [
REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES,
funnelId,
selectedTime,
payload.step_start ?? '',
payload.step_end ?? '',
],
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
});
};
export const useFunnelErrorTraces = (
funnelId: string,
payload: ErrorTracesPayload,
payload: FunnelOverviewPayload,
): UseQueryResult<SuccessResponse<ErrorTraceData> | ErrorResponse, Error> => {
const { selectedTime, validTracesCount } = useFunnelContext();
const {
selectedTime,
validTracesCount,
hasFunnelBeenExecuted,
} = useFunnelContext();
return useQuery({
queryFn: ({ signal }) => getFunnelErrorTraces(funnelId, payload, signal),
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, funnelId, selectedTime],
enabled: !!funnelId && validTracesCount > 0,
queryKey: [
REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES,
funnelId,
selectedTime,
payload.step_start ?? '',
payload.step_end ?? '',
],
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
});
};
@@ -203,6 +230,7 @@ export function useFunnelStepsGraphData(
endTime,
selectedTime,
validTracesCount,
hasFunnelBeenExecuted,
} = useFunnelContext();
return useQuery({
@@ -217,6 +245,31 @@ export function useFunnelStepsGraphData(
funnelId,
selectedTime,
],
enabled: !!funnelId && validTracesCount > 0,
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
});
}
export const useFunnelStepsOverview = (
funnelId: string,
payload: FunnelStepsOverviewPayload,
): UseQueryResult<
SuccessResponse<FunnelStepsOverviewResponse> | ErrorResponse,
Error
> => {
const {
selectedTime,
validTracesCount,
hasFunnelBeenExecuted,
} = useFunnelContext();
return useQuery({
queryFn: ({ signal }) => getFunnelStepsOverview(funnelId, payload, signal),
queryKey: [
REACT_QUERY_KEY.GET_FUNNEL_STEPS_OVERVIEW,
funnelId,
selectedTime,
payload.step_start ?? '',
payload.step_end ?? '',
],
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
});
};

View File

@@ -1,12 +1,16 @@
import { getApDexSettings } from 'api/metrics/ApDex/getApDexSettings';
import { AxiosError, AxiosResponse } from 'axios';
import getApDexSettings from 'api/v1/settings/apdex/services/get';
import { useQuery, UseQueryResult } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { ApDexPayloadAndSettingsProps } from 'types/api/metrics/getApDex';
export const useGetApDexSettings = (
servicename: string,
): UseQueryResult<AxiosResponse<ApDexPayloadAndSettingsProps[]>, AxiosError> =>
useQuery<AxiosResponse<ApDexPayloadAndSettingsProps[]>, AxiosError>({
): UseQueryResult<
SuccessResponseV2<ApDexPayloadAndSettingsProps[]>,
APIError
> =>
useQuery<SuccessResponseV2<ApDexPayloadAndSettingsProps[]>, APIError>({
queryKey: [{ servicename }],
queryFn: async () => getApDexSettings(servicename),
});

View File

@@ -73,18 +73,20 @@ describe('useGetResolvedText', () => {
});
it('should handle different variable formats', () => {
const text = 'Logs in $service.name, {{service.name}}, [[service.name]]';
const text =
'Logs in $service.name, {{service.name}}, [[service.name]] - $dyn-service.name';
const variables = {
'service.name': SERVICE_VAR,
'$dyn-service.name': 'dyn-1, dyn-2',
};
const { result } = renderHookWithProps({ text, variables });
expect(result.current.truncatedText).toBe(
'Logs in test, app +2, test, app +2, test, app +2',
'Logs in test, app +2, test, app +2, test, app +2 - dyn-1, dyn-2',
);
expect(result.current.fullText).toBe(
'Logs in test, app, frontend, env, test, app, frontend, env, test, app, frontend, env',
'Logs in test, app, frontend, env, test, app, frontend, env, test, app, frontend, env - dyn-1, dyn-2',
);
});

View File

@@ -82,11 +82,12 @@ function useGetResolvedText({
const combinedPattern = useMemo(() => {
const escapedMatcher = matcher.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
const variablePatterns = [
`\\{\\{\\s*?\\.([^\\s}]+)\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*([^\\s}]+)\\s*\\}\\}`, // {{var}}
`${escapedMatcher}([\\w.]+)`, // matcher + var.name
`\\[\\[\\s*([^\\s\\]]+)\\s*\\]\\]`, // [[var]]
`\\{\\{\\s*?\\.(${varNamePattern})\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`, // {{var}}
`${escapedMatcher}(${varNamePattern})`, // matcher + var.name
`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`, // [[var]]
];
return new RegExp(variablePatterns.join('|'), 'g');
}, [matcher]);
@@ -94,20 +95,38 @@ function useGetResolvedText({
const extractVarName = useCallback(
(match: string): string => {
// Extract variable name from different formats
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
if (match.startsWith('{{')) {
const dotMatch = match.match(/\{\{\s*\.([^}]+)\}\}/);
const dotMatch = match.match(
new RegExp(`\\{\\{\\s*\\.(${varNamePattern})\\s*\\}\\}`),
);
if (dotMatch) return dotMatch[1].trim();
const normalMatch = match.match(/\{\{\s*([^}]+)\}\}/);
const normalMatch = match.match(
new RegExp(`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`),
);
if (normalMatch) return normalMatch[1].trim();
} else if (match.startsWith('[[')) {
const bracketMatch = match.match(/\[\[\s*([^\]]+)\]\]/);
const bracketMatch = match.match(
new RegExp(`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`),
);
if (bracketMatch) return bracketMatch[1].trim();
} else if (match.startsWith(matcher)) {
return match.substring(matcher.length);
// For $ variables, we always want to strip the prefix
// unless the full match exists in processedVariables
const withoutPrefix = match.substring(matcher.length).trim();
const fullMatch = match.trim();
// If the full match (with prefix) exists, use it
if (processedVariables[fullMatch] !== undefined) {
return fullMatch;
}
// Otherwise return without prefix
return withoutPrefix;
}
return match;
},
[matcher],
[matcher, processedVariables],
);
const fullText = useMemo(() => {

View File

@@ -1,4 +1,5 @@
import { updateServiceConfig } from 'api/integration/aws';
import { S3BucketsByRegion } from 'container/CloudIntegrationPage/ServicesSection/types';
import { useMutation, UseMutationResult } from 'react-query';
interface UpdateServiceConfigPayload {
@@ -6,6 +7,7 @@ interface UpdateServiceConfigPayload {
config: {
logs: {
enabled: boolean;
s3_buckets?: S3BucketsByRegion;
};
metrics: {
enabled: boolean;

View File

@@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from 'react';
/**
* A React hook for interacting with localStorage.
* It allows getting, setting, and removing items from localStorage.
*
* @template T The type of the value to be stored.
* @param {string} key The localStorage key.
* @param {T | (() => T)} initialValue The initial value to use if no value is found in localStorage,
* @returns {[T, (value: T | ((prevState: T) => T)) => void, () => void]}
* A tuple containing:
* - The current value from state (and localStorage).
* - A function to set the value (updates state and localStorage).
* - A function to remove the value from localStorage and reset state to initialValue.
*/
export function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
): [T, (value: T | ((prevState: T) => T)) => void, () => void] {
// This function resolves the initialValue if it's a function,
// and handles potential errors during localStorage access or JSON parsing.
const readValueFromStorage = useCallback((): T => {
const resolvedInitialValue =
initialValue instanceof Function ? initialValue() : initialValue;
try {
const item = window.localStorage.getItem(key);
// If item exists, parse it, otherwise return the resolved initial value.
if (item) {
return JSON.parse(item) as T;
}
} catch (error) {
// Log error and fall back to initial value if reading/parsing fails.
console.warn(`Error reading localStorage key "${key}":`, error);
}
return resolvedInitialValue;
}, [key, initialValue]);
// Initialize state by reading from localStorage.
const [storedValue, setStoredValue] = useState<T>(readValueFromStorage);
// This function updates both localStorage and the React state.
const setValue = useCallback(
(value: T | ((prevState: T) => T)) => {
try {
// If a function is passed to setValue, it receives the latest value from storage.
const latestValueFromStorage = readValueFromStorage();
const valueToStore =
value instanceof Function ? value(latestValueFromStorage) : value;
// Save to localStorage.
window.localStorage.setItem(key, JSON.stringify(valueToStore));
// Update React state.
setStoredValue(valueToStore);
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
},
[key, readValueFromStorage],
);
// This function removes the item from localStorage and resets the React state.
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key);
// Reset state to the (potentially resolved) initialValue.
setStoredValue(
initialValue instanceof Function ? initialValue() : initialValue,
);
} catch (error) {
console.warn(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
// useEffect to update the storedValue if the key changes,
// or if the initialValue prop changes causing readValueFromStorage to change.
// This ensures the hook reflects the correct localStorage item if its key prop dynamically changes.
useEffect(() => {
setStoredValue(readValueFromStorage());
}, [key, readValueFromStorage]); // Re-run if key or the read function changes.
return [storedValue, setValue, removeValue];
}

View File

@@ -46,7 +46,7 @@ export const billingSuccessResponse = {
],
},
],
baseFee: 199,
baseFee: 49,
billTotal: 1278.3,
},
discount: 0,

View File

@@ -0,0 +1,163 @@
export const quickFiltersListResponse = {
status: 'success',
data: {
signal: 'logs',
filters: [
{
key: 'os.description',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'service.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'quantity',
dataType: 'float64',
type: 'tag',
isColumn: false,
isJSON: false,
},
{
key: 'body',
dataType: 'string',
type: '',
isColumn: false,
isJSON: false,
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'service.namespace',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'process.owner',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
],
},
};
export const otherFiltersResponse = {
status: 'success',
data: {
attributes: [
{
key: 'service.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.deployment.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'service.namespace',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.pod.uid',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'os.description',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
],
},
};
export const quickFiltersAttributeValuesResponse = {
status: 'success',
data: {
stringAttributeValues: [
'mq-kafka',
'otel-demo',
'otlp-python',
'sample-flask',
],
numberAttributeValues: null,
boolAttributeValues: null,
},
};

View File

@@ -180,22 +180,21 @@
}
}
}
.request-entity-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: 4px;
border: 0.5px solid rgba(78, 116, 248, 0.2);
background: rgba(69, 104, 220, 0.1);
padding: 12px;
margin: 24px 0;
margin-bottom: 80px;
}
}
}
.request-entity-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: 4px;
border: 0.5px solid rgba(78, 116, 248, 0.2);
background: rgba(69, 104, 220, 0.1);
padding: 12px;
margin: 24px 0;
margin-bottom: 80px;
}
.lightMode {
.integrations-container {

View File

@@ -8,7 +8,20 @@ import { Check } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
export function RequestIntegrationBtn(): JSX.Element {
export enum IntegrationType {
AWS_SERVICES = 'aws-services',
INTEGRATIONS_LIST = 'integrations-list',
}
interface RequestIntegrationBtnProps {
type?: IntegrationType;
message?: string;
}
export function RequestIntegrationBtn({
type,
message,
}: RequestIntegrationBtnProps): JSX.Element {
const [
isSubmittingRequestForIntegration,
setIsSubmittingRequestForIntegration,
@@ -22,8 +35,17 @@ export function RequestIntegrationBtn(): JSX.Element {
const handleRequestIntegrationSubmit = async (): Promise<void> => {
try {
setIsSubmittingRequestForIntegration(true);
const response = await logEvent('Integration Requested', {
screen: 'Integration list page',
const eventName =
type === IntegrationType.AWS_SERVICES
? 'AWS service integration requested'
: 'Integration requested';
const screenName =
type === IntegrationType.AWS_SERVICES
? 'AWS integration details'
: 'Integration list page';
const response = await logEvent(eventName, {
screen: screenName,
integration: requestedIntegrationName,
});
@@ -57,9 +79,7 @@ export function RequestIntegrationBtn(): JSX.Element {
return (
<div className="request-entity-container">
<Typography.Text>
Cannot find what youre looking for? Request more integrations
</Typography.Text>
<Typography.Text>{message}</Typography.Text>
<div className="form-section">
<Space.Compact style={{ width: '100%' }}>
@@ -93,3 +113,8 @@ export function RequestIntegrationBtn(): JSX.Element {
</div>
);
}
RequestIntegrationBtn.defaultProps = {
type: IntegrationType.INTEGRATIONS_LIST,
message: 'Cannot find what youre looking for? Request more integrations',
};

View File

@@ -14,6 +14,11 @@
align-items: center;
gap: 8px;
}
.request-entity-container {
margin: 0;
border-right: none;
border-left: none;
}
}
.lightMode {

View File

@@ -6,7 +6,7 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import LogExplorerQuerySection from 'container/LogExplorerQuerySection';
import LogsExplorerViews from 'container/LogsExplorerViews';
@@ -28,7 +28,7 @@ import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteRe
import { DataSource } from 'types/common/queryBuilder';
import { WrapperStyled } from './styles';
import { LogsQuickFiltersConfig, SELECTED_VIEWS } from './utils';
import { SELECTED_VIEWS } from './utils';
function LogsExplorer(): JSX.Element {
const [showFrequencyChart, setShowFrequencyChart] = useState(true);
@@ -215,8 +215,9 @@ function LogsExplorer(): JSX.Element {
{showFilters && (
<section className={cx('log-quick-filter-left-section')}>
<QuickFilters
className="qf-logs-explorer"
signal={SignalType.LOGS}
source={QuickFiltersSource.LOGS_EXPLORER}
config={LogsQuickFiltersConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</section>

View File

@@ -2,8 +2,8 @@ import { SettingOutlined } from '@ant-design/icons';
import { Popover } from 'antd';
import { IServiceName } from 'container/MetricsApplication/Tabs/types';
import { useGetApDexSettings } from 'hooks/apDex/useGetApDexSettings';
import useErrorNotification from 'hooks/useErrorNotification';
import { useState } from 'react';
import { useNotifications } from 'hooks/useNotifications';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '../styles';
@@ -20,7 +20,16 @@ function ApDexApplication(): JSX.Element {
refetch: refetchGetApDexSetting,
} = useGetApDexSettings(servicename);
const [isOpen, setIsOpen] = useState<boolean>(false);
useErrorNotification(error);
const { notifications } = useNotifications();
useEffect(() => {
if (error) {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
}
}, [error, notifications]);
const handlePopOverClose = (): void => {
setIsOpen(false);

View File

@@ -1,4 +1,6 @@
import { AxiosResponse } from 'axios';
import { StatusCodes } from 'http-status-codes';
import { SuccessResponseV2 } from 'types/api';
import { ApDexPayloadAndSettingsProps } from 'types/api/metrics/getApDex';
export const axiosResponseThresholdData = {
data: [
@@ -6,4 +8,5 @@ export const axiosResponseThresholdData = {
threshold: 0.5,
},
],
} as AxiosResponse;
httpStatusCode: StatusCodes.OK,
} as SuccessResponseV2<ApDexPayloadAndSettingsProps[]>;

View File

@@ -1,10 +1,10 @@
import { AxiosResponse } from 'axios';
import { SuccessResponseV2 } from 'types/api';
import { ApDexPayloadAndSettingsProps } from 'types/api/metrics/getApDex';
export interface ApDexSettingsProps {
servicename: string;
handlePopOverClose: () => void;
isLoading?: boolean;
data?: AxiosResponse<ApDexPayloadAndSettingsProps[]>;
data?: SuccessResponseV2<ApDexPayloadAndSettingsProps[]>;
refetchGetApDexSetting?: () => void;
}

View File

@@ -1,6 +1,7 @@
import './TraceDetailV2.styles.scss';
import { Button, Tabs } from 'antd';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { Compass, Cone, TowerControl, Undo } from 'lucide-react';
@@ -34,6 +35,7 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
history.push(ROUTES.TRACES_EXPLORER);
}
if (activeKey === 'funnels') {
logEvent('Trace Funnels: visited from trace details page', {});
history.push(ROUTES.TRACES_FUNNELS);
}
}}

View File

@@ -39,7 +39,7 @@ function AddFunnelStepDetailsModal({
setStepName(stepData?.name || '');
setDescription(stepData?.description || '');
}
}, [isOpen, stepData]);
}, [isOpen, stepData?.name, stepData?.description]);
const handleCancel = (): void => {
setStepName('');

View File

@@ -26,7 +26,7 @@ function InterStepConfig({
</div>
<div className="inter-step-config__latency-options">
<SignozRadioGroup
value={step.latency_type}
value={step.latency_type ?? LatencyOptions.P99}
options={options}
onChange={(e): void =>
onStepChange(index, {

View File

@@ -1,6 +1,7 @@
import './StepsContent.styles.scss';
import { Button, Steps } from 'antd';
import logEvent from 'api/common/logEvent';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { PlusIcon, Undo2 } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
@@ -28,6 +29,10 @@ function StepsContent({
if (stepWasAdded) {
handleReplaceStep(steps.length, span.serviceName, span.name);
}
logEvent(
'Trace Funnels: span added for a new step from trace details page',
{},
);
}, [span, handleAddStep, handleReplaceStep, steps.length]);
return (
@@ -60,7 +65,8 @@ function StepsContent({
</div>
{/* Display InterStepConfig only between steps */}
{index < steps.length - 1 && (
<InterStepConfig index={index} step={step} />
// the latency type should be sent with the n+1th step
<InterStepConfig index={index + 1} step={steps[index + 1]} />
)}
</div>
}

View File

@@ -34,12 +34,16 @@
border: none;
display: flex;
align-items: center;
gap: 6px;
.ant-btn-icon {
margin-inline-end: 0 !important;
}
&--save {
background-color: var(--bg-slate-400);
font-size: 12px;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
border-radius: 2px;
&--sync {
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-400);
}
&--run {
background-color: var(--bg-robin-500);

View File

@@ -1,8 +1,49 @@
import './StepsFooter.styles.scss';
import { Button, Skeleton } from 'antd';
import { Cone, Play } from 'lucide-react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Cone, Play, RefreshCcw } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useEffect, useMemo } from 'react';
import { useIsFetching, useQueryClient } from 'react-query';
const useFunnelResultsLoading = (): boolean => {
const { funnelId } = useFunnelContext();
const isFetchingFunnelOverview = useIsFetching({
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW, funnelId],
});
const isFetchingStepsGraphData = useIsFetching({
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA, funnelId],
});
const isFetchingErrorTraces = useIsFetching({
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, funnelId],
});
const isFetchingSlowTraces = useIsFetching({
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES, funnelId],
});
return useMemo(() => {
if (!funnelId) {
return false;
}
return (
!!isFetchingFunnelOverview ||
!!isFetchingStepsGraphData ||
!!isFetchingErrorTraces ||
!!isFetchingSlowTraces
);
}, [
funnelId,
isFetchingFunnelOverview,
isFetchingStepsGraphData,
isFetchingErrorTraces,
isFetchingSlowTraces,
]);
};
interface StepsFooterProps {
stepsCount: number;
@@ -14,10 +55,27 @@ function ValidTracesCount(): JSX.Element {
isValidateStepsLoading,
hasIncompleteStepFields,
validTracesCount,
funnelId,
selectedTime,
} = useFunnelContext();
if (isValidateStepsLoading) {
return <Skeleton.Button size="small" />;
}
const queryClient = useQueryClient();
const validationQueryKey = useMemo(
() => [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime],
[funnelId, selectedTime],
);
const validationStatus = queryClient.getQueryData(validationQueryKey);
useEffect(() => {
// Show loading state immediately when fields become valid
if (hasIncompleteStepFields && validationStatus !== 'pending') {
queryClient.setQueryData(validationQueryKey, 'pending');
}
}, [
hasIncompleteStepFields,
queryClient,
validationQueryKey,
validationStatus,
]);
if (hasAllEmptyStepFields) {
return (
@@ -33,6 +91,10 @@ function ValidTracesCount(): JSX.Element {
);
}
if (isValidateStepsLoading || validationStatus === 'pending') {
return <Skeleton.Button size="small" />;
}
if (validTracesCount === 0) {
return (
<span className="steps-footer__valid-traces steps-footer__valid-traces--none">
@@ -45,7 +107,13 @@ function ValidTracesCount(): JSX.Element {
}
function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element {
const { validTracesCount, handleRunFunnel } = useFunnelContext();
const {
validTracesCount,
handleRunFunnel,
hasFunnelBeenExecuted,
} = useFunnelContext();
const isFunnelResultsLoading = useFunnelResultsLoading();
return (
<div className="steps-footer">
@@ -56,15 +124,28 @@ function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element {
<ValidTracesCount />
</div>
<div className="steps-footer__right">
<Button
disabled={validTracesCount === 0}
onClick={handleRunFunnel}
type="primary"
className="steps-footer__button steps-footer__button--run"
icon={<Play size={16} />}
>
Run funnel
</Button>
{!hasFunnelBeenExecuted ? (
<Button
disabled={validTracesCount === 0}
onClick={handleRunFunnel}
type="primary"
className="steps-footer__button steps-footer__button--run"
icon={<Play size={16} />}
>
Run funnel
</Button>
) : (
<Button
type="text"
className="steps-footer__button steps-footer__button--sync"
icon={<RefreshCcw size={16} />}
onClick={handleRunFunnel}
loading={isFunnelResultsLoading}
disabled={validTracesCount === 0}
>
Refresh
</Button>
)}
</div>
</div>
);

View File

@@ -18,7 +18,7 @@ function EmptyFunnelResults({
<div className="empty-funnel-results__title">{title}</div>
<div className="empty-funnel-results__description">{description}</div>
<div className="empty-funnel-results__learn-more">
<LearnMore />
<LearnMore url="https://signoz.io/blog/tracing-funnels-observability-distributed-systems/" />
</div>
</div>
</div>

View File

@@ -1,7 +1,9 @@
import './FunnelResults.styles.scss';
import Spinner from 'components/Spinner';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useQueryClient } from 'react-query';
import EmptyFunnelResults from './EmptyFunnelResults';
import FunnelGraph from './FunnelGraph';
@@ -14,11 +16,17 @@ function FunnelResults(): JSX.Element {
isValidateStepsLoading,
hasIncompleteStepFields,
hasAllEmptyStepFields,
hasFunnelBeenExecuted,
funnelId,
selectedTime,
} = useFunnelContext();
const queryClient = useQueryClient();
if (isValidateStepsLoading) {
return <Spinner size="large" />;
}
const validateQueryData = queryClient.getQueryData([
REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS,
funnelId,
selectedTime,
]);
if (hasAllEmptyStepFields) return <EmptyFunnelResults />;
@@ -30,6 +38,10 @@ function FunnelResults(): JSX.Element {
/>
);
if (isValidateStepsLoading || validateQueryData === 'pending') {
return <Spinner size="large" />;
}
if (validTracesCount === 0) {
return (
<EmptyFunnelResults
@@ -38,6 +50,14 @@ function FunnelResults(): JSX.Element {
/>
);
}
if (!hasFunnelBeenExecuted) {
return (
<EmptyFunnelResults
title="Funnel has not been run yet."
description="Run the funnel to see the results"
/>
);
}
return (
<div className="funnel-results">

View File

@@ -1,4 +1,8 @@
import { ErrorTraceData, SlowTraceData } from 'api/traceFunnels';
import {
ErrorTraceData,
FunnelOverviewPayload,
SlowTraceData,
} from 'api/traceFunnels';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
@@ -15,12 +19,7 @@ interface FunnelTopTracesTableProps {
tooltip: string;
useQueryHook: (
funnelId: string,
payload: {
start_time: number;
end_time: number;
step_a_order: number;
step_b_order: number;
},
payload: FunnelOverviewPayload,
) => UseQueryResult<
SuccessResponse<SlowTraceData | ErrorTraceData> | ErrorResponse,
Error
@@ -40,8 +39,8 @@ function FunnelTopTracesTable({
() => ({
start_time: startTime,
end_time: endTime,
step_a_order: stepAOrder,
step_b_order: stepBOrder,
step_start: stepAOrder,
step_end: stepBOrder,
}),
[startTime, endTime, stepAOrder, stepBOrder],
);

View File

@@ -1,4 +1,4 @@
import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
import { useFunnelStepsMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
import { useParams } from 'react-router-dom';
import FunnelMetricsTable from './FunnelMetricsTable';
@@ -22,7 +22,7 @@ function StepsTransitionMetrics({
(transition) => transition.value === selectedTransition,
);
const { isLoading, metricsData, conversionRate } = useFunnelMetrics({
const { isLoading, metricsData, conversionRate } = useFunnelStepsMetrics({
funnelId: funnelId || '',
stepStart: startStep,
stepEnd: endStep,

View File

@@ -13,7 +13,7 @@ export const topTracesTableColumns = [
),
},
{
title: 'DURATION',
title: 'STEP TRANSITION DURATION',
dataIndex: 'duration_ms',
key: 'duration_ms',
render: (value: string): string => getYAxisFormattedValue(value, 'ms'),

View File

@@ -12,7 +12,7 @@ export const initialStepsData: FunnelStepData[] = [
op: 'and',
},
latency_pointer: 'start',
latency_type: LatencyOptions.P95,
latency_type: undefined,
has_errors: false,
name: '',
description: '',

View File

@@ -1,4 +1,6 @@
import logEvent from 'api/common/logEvent';
import { ValidateFunnelResponse } from 'api/traceFunnels';
import { LOCALSTORAGE } from 'constants/localStorage';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Time } from 'container/TopNav/DateTimeSelection/config';
import {
@@ -6,6 +8,7 @@ import {
Time as TimeV2,
} from 'container/TopNav/DateTimeSelectionV2/config';
import { useValidateFunnelSteps } from 'hooks/TracesFunnels/useFunnels';
import { useLocalStorage } from 'hooks/useLocalStorage';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { initialStepsData } from 'pages/TracesFunnelDetails/constants';
import {
@@ -44,15 +47,15 @@ interface FunnelContextType {
| undefined;
isValidateStepsLoading: boolean;
hasIncompleteStepFields: boolean;
setHasIncompleteStepFields: Dispatch<SetStateAction<boolean>>;
hasAllEmptyStepFields: boolean;
setHasAllEmptyStepFields: Dispatch<SetStateAction<boolean>>;
handleReplaceStep: (
index: number,
serviceName: string,
spanName: string,
) => void;
handleRestoreSteps: (oldSteps: FunnelStepData[]) => void;
hasFunnelBeenExecuted: boolean;
setHasFunnelBeenExecuted: Dispatch<SetStateAction<boolean>>;
}
const FunnelContext = createContext<FunnelContextType | undefined>(undefined);
@@ -83,12 +86,27 @@ export function FunnelProvider({
const funnel = data?.payload;
const initialSteps = funnel?.steps?.length ? funnel.steps : initialStepsData;
const [steps, setSteps] = useState<FunnelStepData[]>(initialSteps);
const [hasIncompleteStepFields, setHasIncompleteStepFields] = useState(
steps.some((step) => step.service_name === '' || step.span_name === ''),
const { hasIncompleteStepFields, hasAllEmptyStepFields } = useMemo(
() => ({
hasAllEmptyStepFields: steps.every(
(step) => step.service_name === '' && step.span_name === '',
),
hasIncompleteStepFields: steps.some(
(step) => step.service_name === '' || step.span_name === '',
),
}),
[steps],
);
const [hasAllEmptyStepFields, setHasAllEmptyStepFields] = useState(
steps.every((step) => step.service_name === '' && step.span_name === ''),
const [unexecutedFunnels, setUnexecutedFunnels] = useLocalStorage<string[]>(
LOCALSTORAGE.UNEXECUTED_FUNNELS,
[],
);
const [hasFunnelBeenExecuted, setHasFunnelBeenExecuted] = useState(
!unexecutedFunnels.includes(funnelId),
);
const {
data: validationResponse,
isLoading: isValidationLoading,
@@ -98,6 +116,7 @@ export function FunnelProvider({
selectedTime,
startTime,
endTime,
enabled: !!funnelId && !!selectedTime && !!startTime && !!endTime,
});
const validTracesCount = useMemo(
@@ -152,6 +171,7 @@ export function FunnelProvider({
service_name: serviceName,
span_name: spanName,
});
logEvent('Trace Funnels: span added (replaced) from trace details page', {});
},
[handleStepUpdate],
);
@@ -161,6 +181,11 @@ export function FunnelProvider({
const handleRunFunnel = useCallback(async (): Promise<void> => {
if (validTracesCount === 0) return;
if (!hasFunnelBeenExecuted) {
setUnexecutedFunnels(unexecutedFunnels.filter((id) => id !== funnelId));
setHasFunnelBeenExecuted(true);
}
queryClient.refetchQueries([
REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW,
funnelId,
@@ -181,7 +206,15 @@ export function FunnelProvider({
funnelId,
selectedTime,
]);
}, [funnelId, queryClient, selectedTime, validTracesCount]);
}, [
funnelId,
hasFunnelBeenExecuted,
unexecutedFunnels,
queryClient,
selectedTime,
setUnexecutedFunnels,
validTracesCount,
]);
const value = useMemo<FunnelContextType>(
() => ({
@@ -200,11 +233,11 @@ export function FunnelProvider({
validationResponse,
isValidateStepsLoading: isValidationLoading || isValidationFetching,
hasIncompleteStepFields,
setHasIncompleteStepFields,
hasAllEmptyStepFields,
setHasAllEmptyStepFields,
handleReplaceStep,
handleRestoreSteps,
hasFunnelBeenExecuted,
setHasFunnelBeenExecuted,
}),
[
funnelId,
@@ -222,11 +255,11 @@ export function FunnelProvider({
isValidationLoading,
isValidationFetching,
hasIncompleteStepFields,
setHasIncompleteStepFields,
hasAllEmptyStepFields,
setHasAllEmptyStepFields,
handleReplaceStep,
handleRestoreSteps,
hasFunnelBeenExecuted,
setHasFunnelBeenExecuted,
],
);

View File

@@ -1,17 +1,20 @@
import '../RenameFunnel/RenameFunnel.styles.scss';
import { Input } from 'antd';
import logEvent from 'api/common/logEvent';
import { AxiosError } from 'axios';
import SignozModal from 'components/SignozModal/SignozModal';
import { LOCALSTORAGE } from 'constants/localStorage';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { useCreateFunnel } from 'hooks/TracesFunnels/useFunnels';
import { useLocalStorage } from 'hooks/useLocalStorage';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Check, X } from 'lucide-react';
import { useState } from 'react';
import { useQueryClient } from 'react-query';
import { generatePath } from 'react-router-dom';
import { generatePath, matchPath, useLocation } from 'react-router-dom';
interface CreateFunnelProps {
isOpen: boolean;
@@ -29,6 +32,12 @@ function CreateFunnel({
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const { safeNavigate } = useSafeNavigate();
const { pathname } = useLocation();
const [unexecutedFunnels, setUnexecutedFunnels] = useLocalStorage<string[]>(
LOCALSTORAGE.UNEXECUTED_FUNNELS,
[],
);
const handleCreate = (): void => {
createFunnelMutation.mutate(
@@ -41,13 +50,26 @@ function CreateFunnel({
notifications.success({
message: 'Funnel created successfully',
});
const eventMessage = matchPath(pathname, ROUTES.TRACE_DETAIL)
? 'Trace Funnels: Funnel created from trace details page'
: 'Trace Funnels: Funnel created from trace funnels list page';
logEvent(eventMessage, {});
setFunnelName('');
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
onClose(data?.payload?.funnel_id);
if (data?.payload?.funnel_id && redirectToDetails) {
const funnelId = data?.payload?.funnel_id;
if (funnelId) {
setUnexecutedFunnels([...unexecutedFunnels, funnelId]);
}
onClose(funnelId);
if (funnelId && redirectToDetails) {
safeNavigate(
generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
funnelId: data.payload.funnel_id,
funnelId,
}),
);
}

View File

@@ -37,7 +37,7 @@ function FunnelsEmptyState({
>
New funnel
</Button>
<LearnMore />
<LearnMore url="https://signoz.io/blog/tracing-funnels-observability-distributed-systems/" />
</div>
</div>
</div>

View File

@@ -40,6 +40,10 @@ function RenameFunnel({
message: 'Funnel renamed successfully',
});
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
queryClient.invalidateQueries([
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
funnelId,
]);
onClose();
},
onError: () => {

View File

@@ -1,5 +1,6 @@
import './TracesModulePage.styles.scss';
import logEvent from 'api/common/logEvent';
import RouteTab from 'components/RouteTab';
import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
@@ -18,6 +19,12 @@ function TracesModulePage(): JSX.Element {
tracesSaveView,
].filter(Boolean) as TabRoutes[];
const handleTabChange = (activeRoute: string): void => {
if (activeRoute === ROUTES.TRACES_FUNNELS) {
logEvent('Trace Funnels: visited from trace explorer page', {});
}
};
return (
<div className="traces-module-container">
<RouteTab
@@ -26,6 +33,7 @@ function TracesModulePage(): JSX.Element {
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
}
history={history}
onChangeHandler={handleTabChange}
/>
</div>
);

View File

@@ -12,3 +12,8 @@ export interface MetricMetaProps {
delta: boolean;
le: number[] | null;
}
export interface PayloadProps {
data: ApDexPayloadAndSettingsProps[];
status: string;
}

Some files were not shown because too many files have changed in this diff Show More