mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-21 21:50:36 +01:00
Compare commits
24 Commits
fix/log-bo
...
issue_5267
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9daa31892a | ||
|
|
ad2f7b8601 | ||
|
|
c7f4aa01c4 | ||
|
|
e93792d427 | ||
|
|
6d0f618a9e | ||
|
|
564d479aae | ||
|
|
253ca7dd7e | ||
|
|
453a524ff4 | ||
|
|
08f92df0a6 | ||
|
|
6e5ff46761 | ||
|
|
72448fa163 | ||
|
|
a45f6341da | ||
|
|
f534ea2467 | ||
|
|
d4843ecd8b | ||
|
|
477b81892d | ||
|
|
9604438723 | ||
|
|
e0a66b7a10 | ||
|
|
b01be1f31c | ||
|
|
c8a007cd8d | ||
|
|
205b23b830 | ||
|
|
68b166f75e | ||
|
|
20d8ca6d7d | ||
|
|
1e1be670f1 | ||
|
|
d1682f2ab6 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -58,6 +58,7 @@ jobs:
|
||||
- role
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -9,6 +9,11 @@ global:
|
||||
# the path component (e.g. /signoz in https://example.com/signoz) is used
|
||||
# as the base path for all HTTP routes (both API and web frontend).
|
||||
external_url: <unset>
|
||||
# origins (scheme://host[:port], no path) allowed as login redirect targets. include
|
||||
# the origin the signoz ui is served on. when not configured, redirect targets are
|
||||
# not validated.
|
||||
# allowed_origins:
|
||||
# - https://signoz.example.com
|
||||
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
|
||||
ingestion_url: <unset>
|
||||
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.
|
||||
|
||||
@@ -8084,6 +8084,19 @@ components:
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableSpanMapperTest:
|
||||
properties:
|
||||
collectorLogs:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
SpantypesGettableSpanMappers:
|
||||
properties:
|
||||
items:
|
||||
@@ -8180,6 +8193,39 @@ components:
|
||||
- name
|
||||
- condition
|
||||
type: object
|
||||
SpantypesPostableSpanMapperTest:
|
||||
properties:
|
||||
groups:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesPostableSpanMapperTestGroup'
|
||||
nullable: true
|
||||
type: array
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- spans
|
||||
- groups
|
||||
type: object
|
||||
SpantypesPostableSpanMapperTestGroup:
|
||||
properties:
|
||||
condition:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupCondition'
|
||||
enabled:
|
||||
type: boolean
|
||||
mappers:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesPostableSpanMapper'
|
||||
nullable: true
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- condition
|
||||
type: object
|
||||
SpantypesPostableTraceAggregations:
|
||||
properties:
|
||||
aggregations:
|
||||
@@ -8341,6 +8387,17 @@ components:
|
||||
- operation
|
||||
- priority
|
||||
type: object
|
||||
SpantypesSpanMapperTestSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
resource:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -14074,6 +14131,69 @@ paths:
|
||||
summary: Update a span mapper
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/span_mapper_groups/test:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Tests how span mappers would transform sample spans
|
||||
operationId: TestSpanMappers
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableSpanMapperTest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMapperTest'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Test span mappers against sample spans
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/stats:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"lodash-es": "^4.17.21",
|
||||
"motion": "12.4.13",
|
||||
"nuqs": "2.8.8",
|
||||
"overlayscrollbars": "^2.8.1",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"papaparse": "5.4.1",
|
||||
"posthog-js": "1.298.0",
|
||||
@@ -221,5 +221,18 @@
|
||||
"*.(scss|css)": [
|
||||
"stylelint"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/core@<=7.29.0": ">=7.29.6 <8",
|
||||
"@istanbuljs/load-nyc-config>js-yaml": ">=4.2.0 <5",
|
||||
"cookie@<0.7.0": ">=0.7.1 <1",
|
||||
"dompurify@<=3.4.10": ">=3.4.11 <4",
|
||||
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1 <0.29.0",
|
||||
"js-cookie@<=3.0.5": ">=3.0.7 <4",
|
||||
"js-yaml@>=4.0.0 <=4.1.1": ">=4.2.0 <5",
|
||||
"prismjs@<1.30.0": ">=1.30.0 <2",
|
||||
"react-router@>=6.7.0 <6.30.4": ">=6.30.4 <7",
|
||||
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
frontend/pnpm-lock.yaml
generated
16
frontend/pnpm-lock.yaml
generated
@@ -187,11 +187,11 @@ importers:
|
||||
specifier: 2.8.8
|
||||
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
|
||||
overlayscrollbars:
|
||||
specifier: ^2.8.1
|
||||
version: 2.9.2
|
||||
specifier: ^2.16.0
|
||||
version: 2.16.0
|
||||
overlayscrollbars-react:
|
||||
specifier: ^0.5.6
|
||||
version: 0.5.6(overlayscrollbars@2.9.2)(react@18.2.0)
|
||||
version: 0.5.6(overlayscrollbars@2.16.0)(react@18.2.0)
|
||||
papaparse:
|
||||
specifier: 5.4.1
|
||||
version: 5.4.1
|
||||
@@ -6941,8 +6941,8 @@ packages:
|
||||
overlayscrollbars: ^2.0.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
overlayscrollbars@2.9.2:
|
||||
resolution: {integrity: sha512-iDT84r39i7oWP72diZN2mbJUsn/taCq568aQaIrc84S87PunBT7qtsVltAF2esk7ORTRjQDnfjVYoqqTzgs8QA==}
|
||||
overlayscrollbars@2.16.0:
|
||||
resolution: {integrity: sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==}
|
||||
|
||||
oxfmt@0.54.0:
|
||||
resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==}
|
||||
@@ -16456,12 +16456,12 @@ snapshots:
|
||||
|
||||
outvariant@1.4.0: {}
|
||||
|
||||
overlayscrollbars-react@0.5.6(overlayscrollbars@2.9.2)(react@18.2.0):
|
||||
overlayscrollbars-react@0.5.6(overlayscrollbars@2.16.0)(react@18.2.0):
|
||||
dependencies:
|
||||
overlayscrollbars: 2.9.2
|
||||
overlayscrollbars: 2.16.0
|
||||
react: 18.2.0
|
||||
|
||||
overlayscrollbars@2.9.2: {}
|
||||
overlayscrollbars@2.16.0: {}
|
||||
|
||||
oxfmt@0.54.0:
|
||||
dependencies:
|
||||
|
||||
20
frontend/src/__tests__/fields_api.util.ts
Normal file
20
frontend/src/__tests__/fields_api.util.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
|
||||
export function mockFieldsAPIsWithEmptyResponse(): void {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/values`, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -9270,6 +9270,48 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributes =
|
||||
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOResource =
|
||||
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
|
||||
|
||||
export interface SpantypesSpanMapperTestSpanDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resource?: SpantypesSpanMapperTestSpanDTOResource;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
collectorLogs?: string[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans?: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
@@ -9611,6 +9653,33 @@ export interface SpantypesPostableSpanMapperGroupDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestGroupDTO {
|
||||
condition: SpantypesSpanMapperGroupConditionDTO | null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
mappers?: SpantypesPostableSpanMapperDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanAggregationDTO {
|
||||
aggregation: SpantypesSpanAggregationTypeDTO;
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
@@ -10961,6 +11030,14 @@ export type UpdateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type TestSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMapperTestDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetStats200Data = { [key: string]: unknown };
|
||||
|
||||
export type GetStats200 = {
|
||||
|
||||
@@ -30,8 +30,10 @@ import type {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
TestSpanMappers200,
|
||||
UpdateSpanMapperGroupPathParameters,
|
||||
UpdateSpanMapperPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -780,3 +782,86 @@ export const useUpdateSpanMapper = <
|
||||
> => {
|
||||
return useMutation(getUpdateSpanMapperMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Tests how span mappers would transform sample spans
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const testSpanMappers = (
|
||||
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<TestSpanMappers200>({
|
||||
url: `/api/v1/span_mapper_groups/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableSpanMapperTestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestSpanMappersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testSpanMappers'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testSpanMappers(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestSpanMappersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>
|
||||
>;
|
||||
export type TestSpanMappersMutationBody =
|
||||
| BodyType<SpantypesPostableSpanMapperTestDTO>
|
||||
| undefined;
|
||||
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const useTestSpanMappers = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestSpanMappersMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -1,148 +1,39 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button, Drawer, Tooltip } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Drawer } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import APIError from 'types/api/error';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import {
|
||||
BarChart,
|
||||
ChevronsLeftRight,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
ScrollText,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import {
|
||||
LogsAggregatorOperator,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
GlobalTimeProvider,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
useGlobalTimeStore,
|
||||
} from 'store/globalTime';
|
||||
|
||||
import { InfraMonitoringEntity, VIEW_TYPES } from '../constants';
|
||||
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
|
||||
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
|
||||
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
|
||||
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
|
||||
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringEventsFilters,
|
||||
useInfraMonitoringLogFilters,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
useInfraMonitoringTracesFilters,
|
||||
useInfraMonitoringView,
|
||||
} from '../hooks';
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
|
||||
import { useInfraMonitoringSelectedItemParams } from '../hooks';
|
||||
import LoadingContainer from '../LoadingContainer';
|
||||
|
||||
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
|
||||
import { K8sBaseDetailsProps } from './types';
|
||||
|
||||
import '../EntityDetailsUtils/entityDetails.styles.scss';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import {
|
||||
EntityCountConfig,
|
||||
EntityCountsSection,
|
||||
} from './components/EntityCountsSection/EntityCountsSection';
|
||||
|
||||
const TimeRangeOffset = 1000000000;
|
||||
export type {
|
||||
CustomTab,
|
||||
CustomTabRenderProps,
|
||||
K8sBaseDetailsProps,
|
||||
K8sDetailsCountConfig,
|
||||
K8sDetailsFilters,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from './types';
|
||||
|
||||
export interface K8sDetailsMetadataConfig<T> {
|
||||
label: string;
|
||||
getValue: (entity: T) => string | number;
|
||||
render?: (value: string | number, entity: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
export type K8sDetailsCountConfig<T> = EntityCountConfig<T>;
|
||||
|
||||
export interface K8sDetailsFilters {
|
||||
filter: { expression: string };
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface CustomTabRenderProps<T> {
|
||||
entity: T;
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: Time;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface CustomTab<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
render: (props: CustomTabRenderProps<T>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface K8sBaseDetailsProps<T> {
|
||||
category: InfraMonitoringEntity;
|
||||
eventCategory: string;
|
||||
// Data fetching configuration
|
||||
getSelectedItemExpression: (params: SelectedItemParams) => string;
|
||||
fetchEntityData: (
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ data: T | null; error?: APIError | null }>;
|
||||
// Entity configuration
|
||||
getEntityName: (entity: T) => string;
|
||||
getInitialLogTracesExpression: (entity: T) => string;
|
||||
getInitialEventsExpression: (entity: T) => string;
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
countsConfig?: K8sDetailsCountConfig<T>[];
|
||||
getCountsFilterExpression?: (entity: T) => string;
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
docPath?: string;
|
||||
}[];
|
||||
getEntityQueryPayload: (
|
||||
entity: T,
|
||||
start: number,
|
||||
end: number,
|
||||
dotMetricsEnabled: boolean,
|
||||
) => GetQueryResultsProps[];
|
||||
queryKeyPrefix: string;
|
||||
/** When true, only metrics are shown and the Metrics/Logs/Traces/Events tab bar is hidden. */
|
||||
hideDetailViewTabs?: boolean;
|
||||
tabsConfig?: {
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
showEvents?: boolean;
|
||||
};
|
||||
customTabs?: Array<CustomTab<T>>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function K8sBaseDetails<T>({
|
||||
category,
|
||||
eventCategory,
|
||||
@@ -163,7 +54,6 @@ export default function K8sBaseDetails<T>({
|
||||
}: K8sBaseDetailsProps<T>): JSX.Element {
|
||||
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
@@ -237,86 +127,9 @@ export default function K8sBaseDetails<T>({
|
||||
|
||||
const entityName = entity ? getEntityName(entity) : '';
|
||||
|
||||
// Content state (previously in K8sBaseDetailsContent)
|
||||
const tabVisibility = useMemo(
|
||||
() => ({
|
||||
showMetrics: true,
|
||||
showLogs: true,
|
||||
showTraces: true,
|
||||
showEvents: true,
|
||||
...tabsConfig,
|
||||
}),
|
||||
[tabsConfig],
|
||||
);
|
||||
|
||||
const { startMs, endMs } = useMemo(
|
||||
() => ({
|
||||
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
|
||||
}),
|
||||
[lastComputedMinMax],
|
||||
);
|
||||
|
||||
const [modalTimeRange, setModalTimeRange] = useState(() => ({
|
||||
startTime: startMs,
|
||||
endTime: endMs,
|
||||
}));
|
||||
|
||||
// TODO(h4ad): Remove this and use context/zustand
|
||||
const lastSelectedInterval = useRef<Time | null>(null);
|
||||
const [selectedInterval, setSelectedInterval] = useState<Time>(
|
||||
lastSelectedInterval.current
|
||||
? lastSelectedInterval.current
|
||||
: isCustomTimeRange(selectedTime)
|
||||
? DEFAULT_TIME_RANGE
|
||||
: selectedTime,
|
||||
);
|
||||
|
||||
const [selectedView, setSelectedView] = useInfraMonitoringView();
|
||||
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
|
||||
|
||||
const validTabs = useMemo(() => {
|
||||
const tabs: string[] = [];
|
||||
if (tabVisibility.showMetrics) {
|
||||
tabs.push(VIEW_TYPES.METRICS);
|
||||
}
|
||||
if (tabVisibility.showLogs) {
|
||||
tabs.push(VIEW_TYPES.LOGS);
|
||||
}
|
||||
if (tabVisibility.showTraces) {
|
||||
tabs.push(VIEW_TYPES.TRACES);
|
||||
}
|
||||
if (tabVisibility.showEvents) {
|
||||
tabs.push(VIEW_TYPES.EVENTS);
|
||||
}
|
||||
if (customTabs) {
|
||||
tabs.push(...customTabs.map((t) => t.key));
|
||||
}
|
||||
return tabs;
|
||||
}, [tabVisibility, customTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
|
||||
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
|
||||
void setSelectedView(firstValid);
|
||||
}
|
||||
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
|
||||
|
||||
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
|
||||
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
|
||||
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
|
||||
const [userLogsExpression] = useQueryState(
|
||||
K8S_ENTITY_LOGS_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
const [userTracesExpression] = useQueryState(
|
||||
K8S_ENTITY_TRACES_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (entity) {
|
||||
logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
void logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
@@ -324,136 +137,6 @@ export default function K8sBaseDetails<T>({
|
||||
}
|
||||
}, [entity, eventCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
|
||||
if (!isCustomTimeRange(currentSelectedInterval)) {
|
||||
setSelectedInterval(currentSelectedInterval);
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / TimeRangeOffset),
|
||||
endTime: Math.floor(maxTime / TimeRangeOffset),
|
||||
});
|
||||
}
|
||||
}, [getMinMaxTime, selectedTime]);
|
||||
|
||||
const handleTabChange = (value: string | null): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setSelectedView(value);
|
||||
setLogFiltersParam(null);
|
||||
setTracesFiltersParam(null);
|
||||
setEventsFiltersParam(null);
|
||||
logEvent(InfraMonitoringEvents.TabChanged, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
|
||||
lastSelectedInterval.current = interval as Time;
|
||||
setSelectedInterval(interval as Time);
|
||||
|
||||
if (interval === 'custom' && dateTimeRange) {
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(dateTimeRange[0] / 1000),
|
||||
endTime: Math.floor(dateTimeRange[1] / 1000),
|
||||
});
|
||||
} else {
|
||||
const { maxTime, minTime } = GetMinMax(interval);
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / TimeRangeOffset),
|
||||
endTime: Math.floor(maxTime / TimeRangeOffset),
|
||||
});
|
||||
}
|
||||
|
||||
logEvent(InfraMonitoringEvents.TimeUpdated, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
interval,
|
||||
view: effectiveView,
|
||||
});
|
||||
},
|
||||
[eventCategory, effectiveView],
|
||||
);
|
||||
|
||||
const handleExplorePagesRedirect = (): void => {
|
||||
const urlQuery = new URLSearchParams();
|
||||
|
||||
if (selectedInterval !== 'custom') {
|
||||
urlQuery.set(QueryParams.relativeTime, selectedInterval);
|
||||
} else {
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
urlQuery.set(QueryParams.startTime, modalTimeRange.startTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, modalTimeRange.endTime.toString());
|
||||
}
|
||||
|
||||
logEvent(InfraMonitoringEvents.ExploreClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: selectedView,
|
||||
});
|
||||
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userLogsExpression || '',
|
||||
);
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
expression: fullExpression,
|
||||
filter: { expression: fullExpression },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userTracesExpression || '',
|
||||
);
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
aggregateOperator: TracesAggregatorOperator.NOOP,
|
||||
expression: fullExpression,
|
||||
filter: { expression: fullExpression },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="70%"
|
||||
@@ -498,207 +181,33 @@ export default function K8sBaseDetails<T>({
|
||||
</div>
|
||||
)}
|
||||
{entity && !isEntityLoading && !hasResponseError && (
|
||||
<>
|
||||
<div className="entity-detail-drawer__entity">
|
||||
<div className="entity-details-grid">
|
||||
<div className="labels-row">
|
||||
{metadataConfig.map((config) => (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
color="muted"
|
||||
className="entity-details-metadata-label"
|
||||
>
|
||||
{config.label}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="values-row">
|
||||
{metadataConfig.map((config) => {
|
||||
const value = config.getValue(entity);
|
||||
const displayValue = String(value);
|
||||
return (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
className="entity-details-metadata-value"
|
||||
>
|
||||
{config.render ? (
|
||||
config.render(value, entity)
|
||||
) : (
|
||||
<Tooltip title={displayValue}>{displayValue}</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{countsConfig &&
|
||||
countsConfig.length > 0 &&
|
||||
selectedItem &&
|
||||
getCountsFilterExpression && (
|
||||
<EntityCountsSection
|
||||
entity={entity}
|
||||
countsConfig={countsConfig}
|
||||
selectedItem={selectedItem}
|
||||
filterExpression={getCountsFilterExpression(entity)}
|
||||
closeDrawer={handleClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hideDetailViewTabs && (
|
||||
<div className="views-tabs-container">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
className="views-tabs"
|
||||
onChange={handleTabChange}
|
||||
value={selectedView}
|
||||
items={[
|
||||
...(tabVisibility.showMetrics
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.METRICS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<BarChart size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showLogs
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.LOGS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<ScrollText size={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showTraces
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.TRACES,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<DraftingCompass size={14} />
|
||||
Traces
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showEvents
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.EVENTS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<ChevronsLeftRight size={14} />
|
||||
Events
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(customTabs?.map((tab) => ({
|
||||
value: tab.key,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</div>
|
||||
),
|
||||
})) ?? []),
|
||||
]}
|
||||
/>
|
||||
|
||||
{selectedView === VIEW_TYPES.LOGS && (
|
||||
<Tooltip title="Go to Logs Explorer" placement="left">
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.TRACES && (
|
||||
<Tooltip title="Go to Traces Explorer" placement="left">
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveView === VIEW_TYPES.METRICS && (
|
||||
<EntityMetrics<T>
|
||||
entity={entity}
|
||||
selectedInterval={selectedInterval}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
isModalTimeSelection
|
||||
entityWidgetInfo={entityWidgetInfo}
|
||||
getEntityQueryPayload={getEntityQueryPayload}
|
||||
category={category}
|
||||
queryKey={`${queryKeyPrefix}Metrics`}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.LOGS && (
|
||||
<EntityLogs
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection
|
||||
handleTimeChange={handleTimeChange}
|
||||
selectedInterval={selectedInterval}
|
||||
queryKey={`${queryKeyPrefix}Logs`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.TRACES && (
|
||||
<EntityTraces
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection
|
||||
handleTimeChange={handleTimeChange}
|
||||
selectedInterval={selectedInterval}
|
||||
queryKey={`${queryKeyPrefix}Traces`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
|
||||
<EntityEvents
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection
|
||||
handleTimeChange={handleTimeChange}
|
||||
selectedInterval={selectedInterval}
|
||||
category={category}
|
||||
queryKey={`${queryKeyPrefix}Events`}
|
||||
initialExpression={eventsInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{customTabs?.map((tab) =>
|
||||
selectedView === tab.key ? (
|
||||
<React.Fragment key={tab.key}>
|
||||
{tab.render({
|
||||
entity,
|
||||
timeRange: modalTimeRange,
|
||||
selectedInterval,
|
||||
handleTimeChange,
|
||||
})}
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
)}
|
||||
</>
|
||||
<GlobalTimeProvider
|
||||
inheritGlobalTime
|
||||
enableUrlParams={{
|
||||
relativeTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
startTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
endTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
}}
|
||||
>
|
||||
<K8sBaseDetailsContent<T>
|
||||
entity={entity}
|
||||
category={category}
|
||||
eventCategory={eventCategory}
|
||||
metadataConfig={metadataConfig}
|
||||
countsConfig={countsConfig}
|
||||
getCountsFilterExpression={getCountsFilterExpression}
|
||||
selectedItem={selectedItem}
|
||||
handleClose={handleClose}
|
||||
entityWidgetInfo={entityWidgetInfo}
|
||||
getEntityQueryPayload={getEntityQueryPayload}
|
||||
queryKeyPrefix={queryKeyPrefix}
|
||||
hideDetailViewTabs={hideDetailViewTabs}
|
||||
tabsConfig={tabsConfig}
|
||||
customTabs={customTabs}
|
||||
logsAndTracesInitialExpression={logsAndTracesInitialExpression}
|
||||
eventsInitialExpression={eventsInitialExpression}
|
||||
/>
|
||||
</GlobalTimeProvider>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import { Fragment, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
ChevronsLeftRight,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import {
|
||||
LogsAggregatorOperator,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { VIEW_TYPES } from '../constants';
|
||||
import { useEntityDetailsTime } from '../EntityDetailsUtils/EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
|
||||
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
|
||||
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
|
||||
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
|
||||
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
|
||||
import {
|
||||
useInfraMonitoringEventsFilters,
|
||||
useInfraMonitoringLogFilters,
|
||||
useInfraMonitoringTracesFilters,
|
||||
useInfraMonitoringView,
|
||||
} from '../hooks';
|
||||
|
||||
import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection';
|
||||
import { K8sBaseDetailsContentProps } from './types';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function K8sBaseDetailsContent<T>({
|
||||
entity,
|
||||
category,
|
||||
eventCategory,
|
||||
metadataConfig,
|
||||
countsConfig,
|
||||
getCountsFilterExpression,
|
||||
selectedItem,
|
||||
handleClose,
|
||||
entityWidgetInfo,
|
||||
getEntityQueryPayload,
|
||||
queryKeyPrefix,
|
||||
hideDetailViewTabs,
|
||||
tabsConfig,
|
||||
customTabs,
|
||||
logsAndTracesInitialExpression,
|
||||
eventsInitialExpression,
|
||||
}: K8sBaseDetailsContentProps<T>): JSX.Element {
|
||||
const { timeRange, selectedInterval, handleTimeChange } =
|
||||
useEntityDetailsTime();
|
||||
|
||||
const tabVisibility = useMemo(
|
||||
() => ({
|
||||
showMetrics: true,
|
||||
showLogs: true,
|
||||
showTraces: true,
|
||||
showEvents: true,
|
||||
...tabsConfig,
|
||||
}),
|
||||
[tabsConfig],
|
||||
);
|
||||
|
||||
const [selectedView, setSelectedView] = useInfraMonitoringView();
|
||||
|
||||
const validTabs = useMemo(() => {
|
||||
const tabs: string[] = [];
|
||||
if (tabVisibility.showMetrics) {
|
||||
tabs.push(VIEW_TYPES.METRICS);
|
||||
}
|
||||
if (tabVisibility.showLogs) {
|
||||
tabs.push(VIEW_TYPES.LOGS);
|
||||
}
|
||||
if (tabVisibility.showTraces) {
|
||||
tabs.push(VIEW_TYPES.TRACES);
|
||||
}
|
||||
if (tabVisibility.showEvents) {
|
||||
tabs.push(VIEW_TYPES.EVENTS);
|
||||
}
|
||||
if (customTabs) {
|
||||
tabs.push(...customTabs.map((t) => t.key));
|
||||
}
|
||||
return tabs;
|
||||
}, [tabVisibility, customTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
|
||||
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
|
||||
void setSelectedView(firstValid);
|
||||
}
|
||||
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
|
||||
|
||||
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
|
||||
|
||||
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
|
||||
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
|
||||
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
|
||||
const [userLogsExpression] = useQueryState(
|
||||
K8S_ENTITY_LOGS_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
const [userTracesExpression] = useQueryState(
|
||||
K8S_ENTITY_TRACES_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
const handleTabChange = (value: string | null): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
void setSelectedView(value);
|
||||
void setLogFiltersParam(null);
|
||||
void setTracesFiltersParam(null);
|
||||
void setEventsFiltersParam(null);
|
||||
void logEvent(InfraMonitoringEvents.TabChanged, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExplorePagesRedirect = (): void => {
|
||||
const urlQuery = new URLSearchParams();
|
||||
|
||||
if (selectedInterval !== 'custom') {
|
||||
urlQuery.set(QueryParams.relativeTime, selectedInterval);
|
||||
} else {
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
// Explorer URL params are in milliseconds, timeRange is in seconds
|
||||
urlQuery.set(QueryParams.startTime, (timeRange.startTime * 1000).toString());
|
||||
urlQuery.set(QueryParams.endTime, (timeRange.endTime * 1000).toString());
|
||||
}
|
||||
|
||||
void logEvent(InfraMonitoringEvents.ExploreClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: selectedView,
|
||||
});
|
||||
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userLogsExpression || '',
|
||||
);
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
expression: fullExpression,
|
||||
filter: { expression: fullExpression },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userTracesExpression || '',
|
||||
);
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
aggregateOperator: TracesAggregatorOperator.NOOP,
|
||||
expression: fullExpression,
|
||||
filter: { expression: fullExpression },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entity-detail-drawer__entity">
|
||||
<div className="entity-details-grid">
|
||||
<div className="labels-row">
|
||||
{metadataConfig.map((config) => (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
color="muted"
|
||||
className="entity-details-metadata-label"
|
||||
>
|
||||
{config.label}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="values-row">
|
||||
{metadataConfig.map((config) => {
|
||||
const value = config.getValue(entity);
|
||||
const displayValue = String(value);
|
||||
return (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
className="entity-details-metadata-value"
|
||||
>
|
||||
{config.render ? (
|
||||
config.render(value, entity)
|
||||
) : (
|
||||
<Tooltip title={displayValue}>{displayValue}</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{countsConfig &&
|
||||
countsConfig.length > 0 &&
|
||||
selectedItem &&
|
||||
getCountsFilterExpression && (
|
||||
<EntityCountsSection
|
||||
entity={entity}
|
||||
countsConfig={countsConfig}
|
||||
selectedItem={selectedItem}
|
||||
filterExpression={getCountsFilterExpression(entity)}
|
||||
closeDrawer={handleClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hideDetailViewTabs && (
|
||||
<div className="views-tabs-container">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
className="views-tabs"
|
||||
onChange={handleTabChange}
|
||||
value={selectedView}
|
||||
items={[
|
||||
...(tabVisibility.showMetrics
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.METRICS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<BarChart size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showLogs
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.LOGS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<ScrollText size={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showTraces
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.TRACES,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<DraftingCompass size={14} />
|
||||
Traces
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showEvents
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.EVENTS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<ChevronsLeftRight size={14} />
|
||||
Events
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(customTabs?.map((tab) => ({
|
||||
value: tab.key,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</div>
|
||||
),
|
||||
})) ?? []),
|
||||
]}
|
||||
/>
|
||||
|
||||
{selectedView === VIEW_TYPES.LOGS && (
|
||||
<Tooltip title="Go to Logs Explorer" placement="left">
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.TRACES && (
|
||||
<Tooltip title="Go to Traces Explorer" placement="left">
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveView === VIEW_TYPES.METRICS && (
|
||||
<EntityMetrics<T>
|
||||
entity={entity}
|
||||
eventEntity={eventCategory}
|
||||
entityWidgetInfo={entityWidgetInfo}
|
||||
getEntityQueryPayload={getEntityQueryPayload}
|
||||
category={category}
|
||||
queryKey={`${queryKeyPrefix}Metrics`}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.LOGS && (
|
||||
<EntityLogs
|
||||
eventEntity={eventCategory}
|
||||
queryKey={`${queryKeyPrefix}Logs`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.TRACES && (
|
||||
<EntityTraces
|
||||
eventEntity={eventCategory}
|
||||
queryKey={`${queryKeyPrefix}Traces`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
|
||||
<EntityEvents
|
||||
eventEntity={eventCategory}
|
||||
queryKey={`${queryKeyPrefix}Events`}
|
||||
initialExpression={eventsInitialExpression}
|
||||
category={category}
|
||||
/>
|
||||
)}
|
||||
{customTabs?.map((tab) =>
|
||||
selectedView === tab.key ? (
|
||||
<Fragment key={tab.key}>
|
||||
{tab.render({
|
||||
entity,
|
||||
timeRange,
|
||||
selectedInterval,
|
||||
handleTimeChange,
|
||||
})}
|
||||
</Fragment>
|
||||
) : null,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -245,6 +245,7 @@ function K8sHeader<TData>({
|
||||
showAutoRefresh={showAutoRefresh}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
defaultRelativeTime="30m"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ export function EntityCountsSection<T>({
|
||||
},
|
||||
};
|
||||
|
||||
// TODO(H4ad): After https://github.com/SigNoz/signoz/pull/12038, inherit custom time of drawer to list
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
|
||||
urlParams.set(
|
||||
@@ -60,6 +59,44 @@ export function EntityCountsSection<T>({
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const currentSearchParams = new URLSearchParams(window.location.search);
|
||||
const detailRelativeTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
);
|
||||
const detailStartTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
);
|
||||
const detailEndTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
);
|
||||
|
||||
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
|
||||
const listStartTime = currentSearchParams.get(QueryParams.startTime);
|
||||
const listEndTime = currentSearchParams.get(QueryParams.endTime);
|
||||
|
||||
if (listRelativeTime) {
|
||||
urlParams.set(QueryParams.relativeTime, listRelativeTime);
|
||||
} else if (listStartTime && listEndTime) {
|
||||
urlParams.set(QueryParams.startTime, listStartTime);
|
||||
urlParams.set(QueryParams.endTime, listEndTime);
|
||||
}
|
||||
|
||||
if (detailRelativeTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
detailRelativeTime,
|
||||
);
|
||||
} else if (detailStartTime && detailEndTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
detailStartTime,
|
||||
);
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
detailEndTime,
|
||||
);
|
||||
}
|
||||
|
||||
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { EntityCountConfig } from './components/EntityCountsSection/EntityCountsSection';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
|
||||
export type K8sDetailsCountConfig<T> = EntityCountConfig<T>;
|
||||
|
||||
export type K8sBaseFilters = {
|
||||
filter: {
|
||||
expression: string;
|
||||
@@ -33,3 +47,100 @@ export type K8sTableRowData<T> = T & {
|
||||
/** Metadata about which attributes were used for grouping */
|
||||
groupedByMeta?: Record<string, string>;
|
||||
};
|
||||
|
||||
export interface K8sDetailsMetadataConfig<T> {
|
||||
label: string;
|
||||
getValue: (entity: T) => string | number;
|
||||
render?: (value: string | number, entity: T) => ReactNode;
|
||||
}
|
||||
|
||||
export interface K8sDetailsFilters {
|
||||
filter: { expression: string };
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface K8sDetailsWidgetInfo {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
}
|
||||
|
||||
export type GetEntityQueryPayload<T> = (
|
||||
entity: T,
|
||||
start: number,
|
||||
end: number,
|
||||
dotMetricsEnabled: boolean,
|
||||
) => GetQueryResultsProps[];
|
||||
|
||||
export interface K8sDetailsTabsConfig {
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
showEvents?: boolean;
|
||||
}
|
||||
|
||||
export interface K8sDetailsCustomTabRenderProps<T> {
|
||||
entity: T;
|
||||
/** Time range in seconds — see useEntityDetailsTime */
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: Time;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface K8sDetailsCustomTab<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
render: (props: K8sDetailsCustomTabRenderProps<T>) => ReactNode;
|
||||
}
|
||||
|
||||
export interface K8sBaseDetailsProps<T> {
|
||||
category: InfraMonitoringEntity;
|
||||
eventCategory: string;
|
||||
// Data fetching configuration
|
||||
getSelectedItemExpression: (params: SelectedItemParams) => string;
|
||||
fetchEntityData: (
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ data: T | null; error?: APIError | null }>;
|
||||
// Entity configuration
|
||||
getEntityName: (entity: T) => string;
|
||||
getInitialLogTracesExpression: (entity: T) => string;
|
||||
getInitialEventsExpression: (entity: T) => string;
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
countsConfig?: K8sDetailsCountConfig<T>[];
|
||||
getCountsFilterExpression?: (entity: T) => string;
|
||||
entityWidgetInfo: K8sDetailsWidgetInfo[];
|
||||
getEntityQueryPayload: GetEntityQueryPayload<T>;
|
||||
queryKeyPrefix: string;
|
||||
/** When true, only metrics are shown and the Metrics/Logs/Traces/Events tab bar is hidden. */
|
||||
hideDetailViewTabs?: boolean;
|
||||
tabsConfig?: K8sDetailsTabsConfig;
|
||||
customTabs?: K8sDetailsCustomTab<T>[];
|
||||
}
|
||||
|
||||
export interface K8sBaseDetailsContentProps<T> {
|
||||
entity: T;
|
||||
category: InfraMonitoringEntity;
|
||||
eventCategory: string;
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
countsConfig?: K8sDetailsCountConfig<T>[];
|
||||
getCountsFilterExpression?: (entity: T) => string;
|
||||
selectedItem: string | null;
|
||||
handleClose: () => void;
|
||||
entityWidgetInfo: K8sDetailsWidgetInfo[];
|
||||
getEntityQueryPayload: GetEntityQueryPayload<T>;
|
||||
queryKeyPrefix: string;
|
||||
hideDetailViewTabs: boolean;
|
||||
tabsConfig?: K8sDetailsTabsConfig;
|
||||
customTabs?: K8sDetailsCustomTab<T>[];
|
||||
logsAndTracesInitialExpression: string;
|
||||
eventsInitialExpression: string;
|
||||
}
|
||||
|
||||
// Aliases for backward compatibility
|
||||
export type CustomTab<T> = K8sDetailsCustomTab<T>;
|
||||
export type CustomTabRenderProps<T> = K8sDetailsCustomTabRenderProps<T>;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Undo } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
|
||||
import { useEntityDetailsTime } from './useEntityDetailsTime';
|
||||
|
||||
interface EntityDateTimeSelectorProps {
|
||||
eventEntity: string;
|
||||
category: InfraMonitoringEntity;
|
||||
view: string;
|
||||
}
|
||||
|
||||
function EntityDateTimeSelector({
|
||||
eventEntity,
|
||||
category,
|
||||
view,
|
||||
}: EntityDateTimeSelectorProps): JSX.Element {
|
||||
const {
|
||||
timeRange,
|
||||
selectedInterval,
|
||||
handleTimeChange,
|
||||
handleResetToParentTime,
|
||||
hasTimeChanged,
|
||||
} = useEntityDetailsTime();
|
||||
|
||||
const onTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
|
||||
void logEvent(InfraMonitoringEvents.TimeUpdated, {
|
||||
entity: eventEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
view,
|
||||
interval,
|
||||
});
|
||||
handleTimeChange(interval, dateTimeRange);
|
||||
},
|
||||
[category, view, eventEntity, handleTimeChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="entity-date-time-selector">
|
||||
{hasTimeChanged && (
|
||||
<TooltipSimple title="Reset to list time" side="bottom">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleResetToParentTime}
|
||||
data-testid="reset-to-list-time-button"
|
||||
prefix={<Undo size={14} />}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection
|
||||
onTimeChange={onTimeChange}
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityDateTimeSelector;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
isCustomTimeRange,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
useGlobalTime,
|
||||
} from 'store/globalTime';
|
||||
|
||||
export interface EntityDetailsTimeRange {
|
||||
/** Unix timestamp in seconds — the unit API payload builders expect. Multiply by 1000 for ms-based UI (modals, URL params). */
|
||||
startTime: number;
|
||||
/** Unix timestamp in seconds — the unit API payload builders expect. Multiply by 1000 for ms-based UI (modals, URL params). */
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface UseEntityDetailsTimeResult {
|
||||
/** Time range in seconds */
|
||||
timeRange: EntityDetailsTimeRange;
|
||||
selectedInterval: Time;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
handleResetToParentTime: () => boolean;
|
||||
canResetToParent: boolean;
|
||||
hasTimeChanged: boolean;
|
||||
}
|
||||
|
||||
export function useEntityDetailsTime(): UseEntityDetailsTimeResult {
|
||||
const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
const lastComputedMinMax = useGlobalTime((s) => s.lastComputedMinMax);
|
||||
const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
|
||||
const handleResetToParentTime = useGlobalTime((s) => s.resetToParentTime);
|
||||
const parentStore = useGlobalTime((s) => s.parentStore);
|
||||
|
||||
const canResetToParent = !!parentStore;
|
||||
|
||||
const hasTimeChanged = useMemo(() => {
|
||||
if (!parentStore) {
|
||||
return false;
|
||||
}
|
||||
return selectedTime !== parentStore.getState().selectedTime;
|
||||
}, [parentStore, selectedTime]);
|
||||
|
||||
const timeRange = useMemo<EntityDetailsTimeRange>(
|
||||
() => ({
|
||||
startTime: Math.floor(
|
||||
lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER / 1000,
|
||||
),
|
||||
endTime: Math.floor(
|
||||
lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER / 1000,
|
||||
),
|
||||
}),
|
||||
[lastComputedMinMax],
|
||||
);
|
||||
|
||||
const selectedInterval = useMemo<Time>(
|
||||
() =>
|
||||
isCustomTimeRange(selectedTime)
|
||||
? ('custom' as Time)
|
||||
: (selectedTime as Time),
|
||||
[selectedTime],
|
||||
);
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
|
||||
if (interval === 'custom' && dateTimeRange) {
|
||||
// DateTimeSelector delivers milliseconds; the store keys custom
|
||||
// ranges by nanoseconds.
|
||||
setSelectedTime(
|
||||
createCustomTimeRange(
|
||||
dateTimeRange[0] * NANO_SECOND_MULTIPLIER,
|
||||
dateTimeRange[1] * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSelectedTime(interval as Time);
|
||||
}
|
||||
},
|
||||
[setSelectedTime],
|
||||
);
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
selectedInterval,
|
||||
handleTimeChange,
|
||||
handleResetToParentTime,
|
||||
canResetToParent,
|
||||
hasTimeChanged,
|
||||
};
|
||||
}
|
||||
@@ -21,17 +21,14 @@ import Controls from 'container/Controls';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
|
||||
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
|
||||
import EntityError from '../EntityError/EntityError';
|
||||
import { EventContents } from './EventsContent';
|
||||
@@ -53,16 +50,7 @@ interface EventDataType {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
selectedInterval: Time;
|
||||
eventEntity: string;
|
||||
queryKey: string;
|
||||
category: InfraMonitoringEntity;
|
||||
initialExpression: string;
|
||||
@@ -77,13 +65,11 @@ const handleExpandRow = (record: EventDataType): JSX.Element => (
|
||||
);
|
||||
|
||||
function EntityEventsContent({
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
selectedInterval,
|
||||
eventEntity,
|
||||
queryKey,
|
||||
category,
|
||||
}: Omit<Props, 'initialExpression'>): JSX.Element {
|
||||
const { timeRange } = useEntityDetailsTime();
|
||||
const expression = useExpression();
|
||||
const inputExpression = useInputExpression();
|
||||
const userExpression = useUserExpression();
|
||||
@@ -131,8 +117,8 @@ function EntityEventsContent({
|
||||
if (validation.isValid) {
|
||||
querySearchOnRun(newUserExpression || '');
|
||||
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: eventEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
view: InfraMonitoringEvents.EventsView,
|
||||
@@ -141,7 +127,14 @@ function EntityEventsContent({
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
|
||||
[
|
||||
inputExpression,
|
||||
initialExpression,
|
||||
refetch,
|
||||
querySearchOnRun,
|
||||
category,
|
||||
eventEntity,
|
||||
],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
@@ -229,16 +222,10 @@ function EntityEventsContent({
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filterContainerTime}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.EventsView}
|
||||
/>
|
||||
|
||||
<RunQueryBtn
|
||||
|
||||
@@ -29,25 +29,25 @@ function verifyEntityEventsV5Request({
|
||||
expect(orderKeys).toContain('timestamp');
|
||||
}
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onTimeChange,
|
||||
}: {
|
||||
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
|
||||
}): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-datetime-selection"
|
||||
onClick={(): void => {
|
||||
onTimeChange?.('5m');
|
||||
}}
|
||||
>
|
||||
Select Time
|
||||
</button>
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
|
||||
useEntityDetailsTime: (): {
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: string;
|
||||
handleTimeChange: jest.Mock;
|
||||
} => ({
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
selectedInterval: '5m',
|
||||
handleTimeChange: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('EntityEvents', () => {
|
||||
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
@@ -69,10 +69,7 @@ describe('EntityEvents', () => {
|
||||
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
|
||||
>
|
||||
<EntityEvents
|
||||
timeRange={{ startTime: 1, endTime: 2 }}
|
||||
isModalTimeSelection={false}
|
||||
handleTimeChange={jest.fn()}
|
||||
selectedInterval="5m"
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression='k8s.pod.name = "x"'
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
|
||||
import { mockQueryRangeV5WithEventsResponse } from '__tests__/query_range_v5.util';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
createGlobalTimeStore,
|
||||
GlobalTimeContext,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import EntityEvents from '../EntityEvents';
|
||||
import { K8S_ENTITY_EVENTS_EXPRESSION_KEY } from '../hooks';
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
|
||||
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
|
||||
|
||||
describe('EntityEvents time range wiring', () => {
|
||||
let capturedPayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPayloads = [];
|
||||
mockQueryRangeV5WithEventsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const body = (await req.json()) as QueryRangePayloadV5;
|
||||
capturedPayloads.push(body);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
mockFieldsAPIsWithEmptyResponse();
|
||||
});
|
||||
|
||||
it('should query the V5 API with the global time range converted to milliseconds', async () => {
|
||||
const store = createGlobalTimeStore();
|
||||
store
|
||||
.getState()
|
||||
.setSelectedTime(
|
||||
createCustomTimeRange(
|
||||
START_MS * NANO_SECOND_MULTIPLIER,
|
||||
END_MS * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
|
||||
const expression = 'k8s.pod.name = "test-pod"';
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=${encodeURIComponent(
|
||||
expression,
|
||||
)}`}
|
||||
>
|
||||
<EntityEvents
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression={expression}
|
||||
/>
|
||||
</NuqsTestingAdapter>
|
||||
</GlobalTimeContext.Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(capturedPayloads[0].start).toBe(START_MS);
|
||||
expect(capturedPayloads[0].end).toBe(END_MS);
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,6 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
@@ -38,6 +33,8 @@ import { ILog } from 'types/api/logs/log';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
|
||||
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
|
||||
import EntityError from '../EntityError/EntityError';
|
||||
import { isKeyNotFoundError } from '../utils';
|
||||
@@ -47,29 +44,18 @@ import { getEntityLogsQueryPayload } from './utils';
|
||||
import styles from './EntityLogs.module.scss';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
selectedInterval: Time;
|
||||
eventEntity: string;
|
||||
queryKey: string;
|
||||
category: InfraMonitoringEntity;
|
||||
initialExpression: string;
|
||||
}
|
||||
|
||||
function EntityLogsContent({
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
selectedInterval,
|
||||
eventEntity,
|
||||
queryKey,
|
||||
category,
|
||||
}: Omit<Props, 'initialExpression'>): JSX.Element {
|
||||
const { timeRange } = useEntityDetailsTime();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const expression = useExpression();
|
||||
@@ -134,7 +120,7 @@ function EntityLogsContent({
|
||||
if (validation.isValid) {
|
||||
querySearchOnRun(newUserExpression);
|
||||
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
@@ -239,16 +225,10 @@ function EntityLogsContent({
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filterContainerTime}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.LogsView}
|
||||
/>
|
||||
|
||||
<RunQueryBtn
|
||||
|
||||
@@ -50,25 +50,25 @@ jest.mock(
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onTimeChange,
|
||||
}: {
|
||||
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
|
||||
}): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-datetime-selection"
|
||||
onClick={(): void => {
|
||||
onTimeChange?.('5m');
|
||||
}}
|
||||
>
|
||||
Select Time
|
||||
</button>
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
|
||||
useEntityDetailsTime: (): {
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: string;
|
||||
handleTimeChange: jest.Mock;
|
||||
} => ({
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
selectedInterval: '5m',
|
||||
handleTimeChange: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('EntityLogs', () => {
|
||||
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
|
||||
const itemHeight = 100;
|
||||
@@ -94,10 +94,7 @@ describe('EntityLogs', () => {
|
||||
>
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight }}>
|
||||
<EntityLogs
|
||||
timeRange={{ startTime: 1, endTime: 2 }}
|
||||
isModalTimeSelection={false}
|
||||
handleTimeChange={jest.fn()}
|
||||
selectedInterval="5m"
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression='k8s.pod.name = "x"'
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
|
||||
import { mockQueryRangeV5WithLogsResponse } from '__tests__/query_range_v5.util';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
createGlobalTimeStore,
|
||||
GlobalTimeContext,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import EntityLogs from '../EntityLogs';
|
||||
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../hooks';
|
||||
|
||||
jest.mock(
|
||||
'components/OverlayScrollbar/OverlayScrollbar',
|
||||
() =>
|
||||
function MockOverlayScrollbar({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
|
||||
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
|
||||
|
||||
describe('EntityLogs time range wiring', () => {
|
||||
let capturedPayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPayloads = [];
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const body = (await req.json()) as QueryRangePayloadV5;
|
||||
capturedPayloads.push(body);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
mockFieldsAPIsWithEmptyResponse();
|
||||
});
|
||||
|
||||
it('should query the V5 API with the global time range converted to milliseconds', async () => {
|
||||
const store = createGlobalTimeStore();
|
||||
store
|
||||
.getState()
|
||||
.setSelectedTime(
|
||||
createCustomTimeRange(
|
||||
START_MS * NANO_SECOND_MULTIPLIER,
|
||||
END_MS * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
|
||||
const expression = 'k8s.pod.name = "test-pod"';
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`${K8S_ENTITY_LOGS_EXPRESSION_KEY}=${encodeURIComponent(
|
||||
expression,
|
||||
)}`}
|
||||
>
|
||||
<EntityLogs
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression={expression}
|
||||
/>
|
||||
</NuqsTestingAdapter>
|
||||
</GlobalTimeContext.Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
expect(capturedPayloads[0].start).toBe(START_MS);
|
||||
expect(capturedPayloads[0].end).toBe(END_MS);
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,11 @@ import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -24,25 +20,17 @@ import { getMetricsExplorerUrl } from 'utils/explorerUtils';
|
||||
import { buildEntityMetricsChartConfig } from './configBuilder';
|
||||
import ChartHeader from './ChartHeader';
|
||||
|
||||
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
|
||||
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import { useEntityMetrics } from './hooks';
|
||||
import { isKeyNotFoundError } from '../utils';
|
||||
|
||||
import styles from './EntityMetrics.module.scss';
|
||||
import { MetricsTable } from './MetricsTable';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
interface EntityMetricsProps<T> {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
selectedInterval: Time;
|
||||
entity: T;
|
||||
eventEntity: string;
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
@@ -59,16 +47,16 @@ interface EntityMetricsProps<T> {
|
||||
}
|
||||
|
||||
function EntityMetrics<T>({
|
||||
selectedInterval,
|
||||
entity,
|
||||
timeRange,
|
||||
handleTimeChange,
|
||||
isModalTimeSelection,
|
||||
eventEntity,
|
||||
entityWidgetInfo,
|
||||
getEntityQueryPayload,
|
||||
queryKey,
|
||||
category,
|
||||
}: EntityMetricsProps<T>): JSX.Element {
|
||||
const { timeRange, selectedInterval, handleTimeChange } =
|
||||
useEntityDetailsTime();
|
||||
|
||||
const { visibilities, setElement } = useMultiIntersectionObserver(
|
||||
entityWidgetInfo.length,
|
||||
{ threshold: 0.1 },
|
||||
@@ -91,10 +79,8 @@ function EntityMetrics<T>({
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
handleTimeChange('custom', [startTimestamp, endTimestamp]);
|
||||
// UPlotConfigBuilder's setSelect hook already delivers milliseconds
|
||||
handleTimeChange('custom', [Math.trunc(start), Math.trunc(end)]);
|
||||
},
|
||||
[handleTimeChange],
|
||||
);
|
||||
@@ -188,16 +174,10 @@ function EntityMetrics<T>({
|
||||
return (
|
||||
<>
|
||||
<div className={styles.metricsHeader}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime={DEFAULT_TIME_RANGE}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.MetricsView}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.entityMetricsContainer}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import { LicenseEvent } from 'types/api/licensesV3/getActive';
|
||||
import uPlot from 'uplot';
|
||||
@@ -28,13 +27,28 @@ jest.mock('lib/uPlotV2/utils/dataUtils', () => ({
|
||||
hasSingleVisiblePoint: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="date-time-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockTimeRange = { startTime: 1705315200, endTime: 1705318800 };
|
||||
let mockSelectedInterval = '5m';
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
|
||||
useEntityDetailsTime: (): {
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: string;
|
||||
handleTimeChange: jest.Mock;
|
||||
} => ({
|
||||
timeRange: mockTimeRange,
|
||||
selectedInterval: mockSelectedInterval,
|
||||
handleTimeChange: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries',
|
||||
() => ({
|
||||
@@ -144,13 +158,6 @@ const mockGetEntityQueryPayload = jest.fn().mockReturnValue([
|
||||
},
|
||||
]);
|
||||
|
||||
const mockTimeRange = {
|
||||
startTime: 1705315200,
|
||||
endTime: 1705318800,
|
||||
};
|
||||
|
||||
const mockHandleTimeChange = jest.fn();
|
||||
|
||||
const mockQueries = [
|
||||
{
|
||||
data: {
|
||||
@@ -283,10 +290,6 @@ const mockEmptyQueries = [
|
||||
|
||||
const renderEntityMetrics = (overrides = {}): any => {
|
||||
const defaultProps = {
|
||||
timeRange: mockTimeRange,
|
||||
isModalTimeSelection: false,
|
||||
handleTimeChange: mockHandleTimeChange,
|
||||
selectedInterval: '5m' as Time,
|
||||
entity: mockEntity,
|
||||
entityWidgetInfo: mockEntityWidgetInfo,
|
||||
getEntityQueryPayload: mockGetEntityQueryPayload,
|
||||
@@ -298,11 +301,8 @@ const renderEntityMetrics = (overrides = {}): any => {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<EntityMetrics
|
||||
timeRange={defaultProps.timeRange}
|
||||
isModalTimeSelection={defaultProps.isModalTimeSelection}
|
||||
handleTimeChange={defaultProps.handleTimeChange}
|
||||
selectedInterval={defaultProps.selectedInterval}
|
||||
entity={defaultProps.entity}
|
||||
eventEntity="test"
|
||||
entityWidgetInfo={defaultProps.entityWidgetInfo}
|
||||
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
|
||||
queryKey={defaultProps.queryKey}
|
||||
@@ -344,6 +344,7 @@ const mockQueryPayloads = [
|
||||
describe('EntityMetrics', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSelectedInterval = '5m';
|
||||
mockUseEntityMetrics.mockReturnValue({
|
||||
queries: mockQueries as any,
|
||||
chartData: mockChartData,
|
||||
@@ -454,7 +455,8 @@ describe('EntityMetrics', () => {
|
||||
});
|
||||
|
||||
it('builds metrics explorer link with relativeTime when a relative interval is selected', () => {
|
||||
renderEntityMetrics({ selectedInterval: '5m' as Time });
|
||||
mockSelectedInterval = '5m';
|
||||
renderEntityMetrics();
|
||||
const href = screen
|
||||
.getByTestId('open-metrics-explorer-0')
|
||||
.getAttribute('href');
|
||||
@@ -464,7 +466,8 @@ describe('EntityMetrics', () => {
|
||||
});
|
||||
|
||||
it('builds metrics explorer link with absolute time range in milliseconds for custom interval', () => {
|
||||
renderEntityMetrics({ selectedInterval: 'custom' as Time });
|
||||
mockSelectedInterval = 'custom';
|
||||
renderEntityMetrics();
|
||||
const href = screen
|
||||
.getByTestId('open-metrics-explorer-0')
|
||||
.getAttribute('href');
|
||||
@@ -478,7 +481,6 @@ describe('EntityMetrics', () => {
|
||||
expect(mockUseEntityMetrics).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: 'test-query-key',
|
||||
timeRange: mockTimeRange,
|
||||
entity: mockEntity,
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
createGlobalTimeStore,
|
||||
GlobalTimeContext,
|
||||
GlobalTimeStoreApi,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime';
|
||||
import { act, render } from 'tests/test-utils';
|
||||
|
||||
import { buildEntityMetricsChartConfig } from '../configBuilder';
|
||||
import EntityMetrics from '../EntityMetrics';
|
||||
|
||||
jest.mock('../configBuilder', () => ({
|
||||
buildEntityMetricsChartConfig: jest.fn().mockReturnValue({
|
||||
getId: jest.fn().mockReturnValue('mock-id'),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="date-time-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockBuildChartConfig = buildEntityMetricsChartConfig as jest.Mock;
|
||||
|
||||
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
|
||||
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
|
||||
const START_SECONDS = 1705315200;
|
||||
const END_SECONDS = 1705318800;
|
||||
|
||||
const entity = { id: 'test-entity-1' };
|
||||
|
||||
// The jsdom IntersectionObserver polyfill never reports visibility, so the
|
||||
// queries stay disabled and no network request is issued.
|
||||
const queryPayload = {
|
||||
graphType: PANEL_TYPES.TIME_SERIES,
|
||||
} as unknown as GetQueryResultsProps;
|
||||
|
||||
function createStoreWithCustomRange(): GlobalTimeStoreApi {
|
||||
const store = createGlobalTimeStore();
|
||||
store
|
||||
.getState()
|
||||
.setSelectedTime(
|
||||
createCustomTimeRange(
|
||||
START_MS * NANO_SECOND_MULTIPLIER,
|
||||
END_MS * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
return store;
|
||||
}
|
||||
|
||||
function renderEntityMetrics(store: GlobalTimeStoreApi): jest.Mock {
|
||||
const getEntityQueryPayload = jest.fn().mockReturnValue([queryPayload]);
|
||||
render(
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
<EntityMetrics
|
||||
entity={entity}
|
||||
eventEntity="test"
|
||||
entityWidgetInfo={[{ title: 'CPU Usage', yAxisUnit: 'percentage' }]}
|
||||
getEntityQueryPayload={getEntityQueryPayload}
|
||||
queryKey="test-query-key"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
/>
|
||||
</GlobalTimeContext.Provider>,
|
||||
);
|
||||
return getEntityQueryPayload;
|
||||
}
|
||||
|
||||
describe('EntityMetrics time range wiring', () => {
|
||||
beforeEach(() => {
|
||||
mockBuildChartConfig.mockClear();
|
||||
});
|
||||
|
||||
it('should build the metrics query payloads and chart config from the global time range in seconds', () => {
|
||||
const store = createStoreWithCustomRange();
|
||||
|
||||
const getEntityQueryPayload = renderEntityMetrics(store);
|
||||
|
||||
expect(getEntityQueryPayload).toHaveBeenCalledWith(
|
||||
entity,
|
||||
START_SECONDS,
|
||||
END_SECONDS,
|
||||
false,
|
||||
);
|
||||
expect(mockBuildChartConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
minTimeScale: START_SECONDS,
|
||||
maxTimeScale: END_SECONDS,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should store a drag selection (received in milliseconds) as the equivalent custom range', () => {
|
||||
const store = createStoreWithCustomRange();
|
||||
|
||||
renderEntityMetrics(store);
|
||||
|
||||
const { onDragSelect } = mockBuildChartConfig.mock.calls[0][0];
|
||||
|
||||
// UPlotConfigBuilder's setSelect hook calls onDragSelect with milliseconds
|
||||
const dragStartMs = 1705316000000;
|
||||
const dragEndMs = 1705317000000;
|
||||
act(() => {
|
||||
onDragSelect(dragStartMs, dragEndMs);
|
||||
});
|
||||
|
||||
expect(store.getState().selectedTime).toBe(
|
||||
createCustomTimeRange(
|
||||
dragStartMs * NANO_SECOND_MULTIPLIER,
|
||||
dragEndMs * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,11 +20,6 @@ import { InfraMonitoringEvents } from 'constants/events';
|
||||
import Controls from 'container/Controls';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import { useQueryState } from 'nuqs';
|
||||
@@ -32,10 +27,11 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
|
||||
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
|
||||
import EntityError from '../EntityError/EntityError';
|
||||
import { selectedEntityTracesColumns } from '../utils';
|
||||
import { isKeyNotFoundError } from '../utils';
|
||||
import { isKeyNotFoundError, selectedEntityTracesColumns } from '../utils';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY, useEntityTraces } from './hooks';
|
||||
import { getTraceListColumns } from './traceListColumns';
|
||||
import { getEntityTracesQueryPayload } from './utils';
|
||||
@@ -44,29 +40,18 @@ import styles from './EntityTraces.module.scss';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
selectedInterval: Time;
|
||||
eventEntity: string;
|
||||
queryKey: string;
|
||||
category: InfraMonitoringEntity;
|
||||
initialExpression: string;
|
||||
}
|
||||
|
||||
function EntityTracesContent({
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
selectedInterval,
|
||||
eventEntity,
|
||||
queryKey,
|
||||
category,
|
||||
}: Omit<Props, 'initialExpression'>): JSX.Element {
|
||||
const { timeRange } = useEntityDetailsTime();
|
||||
const expression = useExpression();
|
||||
const inputExpression = useInputExpression();
|
||||
const userExpression = useUserExpression();
|
||||
@@ -114,8 +99,8 @@ function EntityTracesContent({
|
||||
if (validation.isValid) {
|
||||
querySearchOnRun(newUserExpression || '');
|
||||
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: eventEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
view: InfraMonitoringEvents.TracesView,
|
||||
@@ -124,7 +109,14 @@ function EntityTracesContent({
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
|
||||
[
|
||||
inputExpression,
|
||||
initialExpression,
|
||||
refetch,
|
||||
querySearchOnRun,
|
||||
category,
|
||||
eventEntity,
|
||||
],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
@@ -152,7 +144,7 @@ function EntityTracesContent({
|
||||
const hasAdditionalFilters = !!userExpression?.trim();
|
||||
|
||||
const handleRowClick = useCallback(() => {
|
||||
logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
void logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
category,
|
||||
view: InfraMonitoringEvents.TracesView,
|
||||
@@ -169,16 +161,10 @@ function EntityTracesContent({
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filterContainerTime}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.TracesView}
|
||||
/>
|
||||
|
||||
<RunQueryBtn
|
||||
|
||||
@@ -34,10 +34,8 @@ describe('EntityTraces - Default Behavior', () => {
|
||||
});
|
||||
|
||||
it('should pass time range to API (converted to milliseconds)', async () => {
|
||||
const timeRange = { startTime: 1000, endTime: 2000 };
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces({ timeRange });
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -47,8 +45,8 @@ describe('EntityTraces - Default Behavior', () => {
|
||||
verifyQueryPayload({
|
||||
payload: capturedPayloads[0],
|
||||
expectedTimeRange: {
|
||||
start: timeRange.startTime * 1000,
|
||||
end: timeRange.endTime * 1000,
|
||||
start: 1 * 1000,
|
||||
end: 2 * 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
|
||||
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
createGlobalTimeStore,
|
||||
GlobalTimeContext,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import EntityTraces from '../EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
|
||||
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
|
||||
|
||||
describe('EntityTraces time range wiring', () => {
|
||||
let capturedPayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPayloads = [];
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const body = (await req.json()) as QueryRangePayloadV5;
|
||||
capturedPayloads.push(body);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
mockFieldsAPIsWithEmptyResponse();
|
||||
});
|
||||
|
||||
it('should query the V5 API with the global time range converted to milliseconds', async () => {
|
||||
const store = createGlobalTimeStore();
|
||||
store
|
||||
.getState()
|
||||
.setSelectedTime(
|
||||
createCustomTimeRange(
|
||||
START_MS * NANO_SECOND_MULTIPLIER,
|
||||
END_MS * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
|
||||
const expression = 'k8s.pod.name = "test-pod"';
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`${K8S_ENTITY_TRACES_EXPRESSION_KEY}=${encodeURIComponent(
|
||||
expression,
|
||||
)}`}
|
||||
>
|
||||
<EntityTraces
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression={expression}
|
||||
/>
|
||||
</NuqsTestingAdapter>
|
||||
</GlobalTimeContext.Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(capturedPayloads[0].start).toBe(START_MS);
|
||||
expect(capturedPayloads[0].end).toBe(END_MS);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, RenderResult } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
@@ -9,39 +7,19 @@ import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
import EntityTraces from '../EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
|
||||
|
||||
// QuerySearch fires autocomplete requests on mount; without handlers MSW
|
||||
// passes them through to the real network and the resulting AxiosError fails
|
||||
// whichever test happens to be running.
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/values`, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
mockFieldsAPIsWithEmptyResponse();
|
||||
});
|
||||
|
||||
export interface RenderEntityTracesOptions {
|
||||
expression?: string;
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
category?: InfraMonitoringEntity;
|
||||
selectedInterval?: '5m' | '15m' | '30m' | '1h';
|
||||
pagination?: { offset: number; limit: number };
|
||||
}
|
||||
|
||||
export function renderEntityTraces({
|
||||
expression = 'k8s.pod.name = "test-pod"',
|
||||
timeRange = { startTime: 1, endTime: 2 },
|
||||
category = InfraMonitoringEntity.PODS,
|
||||
selectedInterval = '5m',
|
||||
pagination,
|
||||
}: RenderEntityTracesOptions = {}): RenderResult {
|
||||
const encodedExpression = encodeURIComponent(expression);
|
||||
@@ -55,10 +33,7 @@ export function renderEntityTraces({
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<EntityTraces
|
||||
timeRange={timeRange}
|
||||
isModalTimeSelection={false}
|
||||
handleTimeChange={jest.fn()}
|
||||
selectedInterval={selectedInterval}
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={category}
|
||||
initialExpression={expression}
|
||||
@@ -101,21 +76,21 @@ export function verifyQueryPayload({
|
||||
expect(orderKeys).toContain('timestamp');
|
||||
}
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onTimeChange,
|
||||
}: {
|
||||
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
|
||||
}): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-datetime-selection"
|
||||
onClick={(): void => {
|
||||
onTimeChange?.('5m');
|
||||
}}
|
||||
>
|
||||
Select Time
|
||||
</button>
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
|
||||
useEntityDetailsTime: (): {
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: string;
|
||||
handleTimeChange: jest.Mock;
|
||||
} => ({
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
selectedInterval: '5m',
|
||||
handleTimeChange: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Container } from '@signozhq/icons';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
|
||||
import { CustomTab } from '../Base/K8sBaseDetails';
|
||||
@@ -10,6 +11,20 @@ import {
|
||||
|
||||
import EntityMetrics from './EntityMetrics';
|
||||
|
||||
const categoryToEventEntity: Record<InfraMonitoringEntity, string> = {
|
||||
[InfraMonitoringEntity.DAEMONSETS]: InfraMonitoringEvents.DaemonSet,
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: InfraMonitoringEvents.Deployment,
|
||||
[InfraMonitoringEntity.JOBS]: InfraMonitoringEvents.Job,
|
||||
[InfraMonitoringEntity.NAMESPACES]: InfraMonitoringEvents.Namespace,
|
||||
[InfraMonitoringEntity.STATEFULSETS]: InfraMonitoringEvents.StatefulSet,
|
||||
[InfraMonitoringEntity.PODS]: InfraMonitoringEvents.Pod,
|
||||
[InfraMonitoringEntity.NODES]: InfraMonitoringEvents.Node,
|
||||
[InfraMonitoringEntity.CLUSTERS]: InfraMonitoringEvents.Cluster,
|
||||
[InfraMonitoringEntity.VOLUMES]: InfraMonitoringEvents.Volume,
|
||||
[InfraMonitoringEntity.HOSTS]: InfraMonitoringEvents.HostEntity,
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'container',
|
||||
};
|
||||
|
||||
interface CreatePodMetricsTabParams<T> {
|
||||
getQueryPayload: (
|
||||
entity: T,
|
||||
@@ -17,30 +32,29 @@ interface CreatePodMetricsTabParams<T> {
|
||||
end: number,
|
||||
dotMetricsEnabled: boolean,
|
||||
) => GetQueryResultsProps[];
|
||||
category: InfraMonitoringEntity;
|
||||
queryKey: string;
|
||||
category: InfraMonitoringEntity;
|
||||
}
|
||||
|
||||
export function createPodMetricsTab<T>({
|
||||
getQueryPayload,
|
||||
category,
|
||||
queryKey,
|
||||
category,
|
||||
}: CreatePodMetricsTabParams<T>): CustomTab<T> {
|
||||
const eventEntity = categoryToEventEntity[category];
|
||||
|
||||
return {
|
||||
key: VIEW_TYPES.POD_METRICS,
|
||||
label: 'Pod Metrics',
|
||||
icon: <Container size={14} />,
|
||||
render: ({ entity, timeRange, selectedInterval, handleTimeChange }) => (
|
||||
render: ({ entity }) => (
|
||||
<EntityMetrics
|
||||
entity={entity}
|
||||
selectedInterval={selectedInterval}
|
||||
timeRange={timeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
isModalTimeSelection
|
||||
eventEntity={eventEntity}
|
||||
entityWidgetInfo={podUtilizationByPodWidgetInfo}
|
||||
getEntityQueryPayload={getQueryPayload}
|
||||
category={category}
|
||||
queryKey={queryKey}
|
||||
category={category}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -454,3 +454,9 @@
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.entity-date-time-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -905,6 +905,9 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
|
||||
SELECTED_ITEM: 'selectedItem',
|
||||
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
|
||||
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
|
||||
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
|
||||
DETAIL_START_TIME: 'detailStartTime',
|
||||
DETAIL_END_TIME: 'detailEndTime',
|
||||
};
|
||||
|
||||
/** Metric namespace prefixes for /fields/keys and /fields/values APIs */
|
||||
|
||||
@@ -7,6 +7,7 @@ import AppRoutes from 'AppRoutes';
|
||||
import { AxiosError } from 'axios';
|
||||
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
|
||||
import { ThemeProvider } from 'hooks/useDarkMode';
|
||||
import { configureOverlayScrollbars } from 'lib/configureOverlayScrollbars';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
@@ -17,6 +18,8 @@ import './ReactI18';
|
||||
|
||||
import 'styles.scss';
|
||||
|
||||
configureOverlayScrollbars();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
22
frontend/src/lib/configureOverlayScrollbars.ts
Normal file
22
frontend/src/lib/configureOverlayScrollbars.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { OverlayScrollbars } from 'overlayscrollbars';
|
||||
|
||||
/**
|
||||
* Disables the content `elementEvents` option (default: `[['img', 'load']]`)
|
||||
* for every OverlayScrollbars instance.
|
||||
*
|
||||
* The per-element event cleanups OverlayScrollbars stores in its internal
|
||||
* WeakMap close over the MutationObserver callback scope, which holds arrays
|
||||
* with every node added/removed in that mutation batch. A single long-lived
|
||||
* reference to one of those elements (e.g. CodeMirror's EditContext or its
|
||||
* module-level scratch Range pinning a detached editor) then retains entire
|
||||
* unmounted subtrees as detached DOM — ~1.3k nodes per InfraMonitoring
|
||||
* category switch.
|
||||
*
|
||||
* Content size changes from loading images are still handled by the
|
||||
* scrollbars' size observer, so scrollbar geometry stays correct.
|
||||
*/
|
||||
export function configureOverlayScrollbars(): void {
|
||||
OverlayScrollbars.env().setDefaultOptions({
|
||||
update: { elementEvents: null },
|
||||
});
|
||||
}
|
||||
@@ -23,12 +23,14 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import history from 'lib/history';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -69,6 +71,7 @@ function DashboardActions({
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
const { user } = useAppContext();
|
||||
@@ -112,6 +115,11 @@ function DashboardActions({
|
||||
setIsCloning(true);
|
||||
const response = await cloneDashboardV2({ id: dashboard.id });
|
||||
toast.success('Dashboard cloned');
|
||||
void logEvent(DashboardDetailEvents.Cloned, {
|
||||
dashboardId: dashboard.id,
|
||||
dashboardName: title,
|
||||
source: 'detail',
|
||||
});
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
@@ -120,16 +128,43 @@ function DashboardActions({
|
||||
} finally {
|
||||
setIsCloning(false);
|
||||
}
|
||||
}, [dashboard.id, safeNavigate, showErrorModal]);
|
||||
}, [dashboard.id, title, safeNavigate, showErrorModal]);
|
||||
|
||||
const handleConfirmDelete = useCallback((): void => {
|
||||
deleteDashboardMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
void logEvent(DashboardDetailEvents.Deleted, {
|
||||
dashboardId: dashboard.id,
|
||||
panelCount: Object.keys(dashboard.spec.panels).length,
|
||||
});
|
||||
setIsDeleteOpen(false);
|
||||
history.replace(ROUTES.ALL_DASHBOARD);
|
||||
},
|
||||
});
|
||||
}, [deleteDashboardMutation]);
|
||||
}, [deleteDashboardMutation, dashboard.id, dashboard.spec.panels]);
|
||||
|
||||
const handleOpenSettings = useCallback((): void => {
|
||||
void logEvent(DashboardDetailEvents.SettingsOpened, {
|
||||
dashboardId: dashboard.id,
|
||||
});
|
||||
setIsSettingsDrawerOpen(true);
|
||||
}, [dashboard.id]);
|
||||
|
||||
const handleOpenJsonEditor = useCallback((): void => {
|
||||
void logEvent(DashboardDetailEvents.JsonEditorOpened, {
|
||||
dashboardId: dashboard.id,
|
||||
readOnly: !isEditable,
|
||||
});
|
||||
setIsJsonEditorOpen(true);
|
||||
}, [dashboard.id, isEditable]);
|
||||
|
||||
const handleEnterFullScreen = useCallback((): void => {
|
||||
void logEvent(DashboardDetailEvents.FullScreenToggled, {
|
||||
dashboardId: dashboard.id,
|
||||
enabled: true,
|
||||
});
|
||||
void handle.enter();
|
||||
}, [dashboard.id, handle]);
|
||||
|
||||
// Shown only to edit-permitted users, so the only disabled reason is the lock.
|
||||
const editLabel = useCallback(
|
||||
@@ -176,7 +211,7 @@ function DashboardActions({
|
||||
key: 'fullscreen',
|
||||
label: 'Full screen',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: handle.enter,
|
||||
onClick: handleEnterFullScreen,
|
||||
});
|
||||
|
||||
const items: MenuItem[] = [
|
||||
@@ -228,7 +263,7 @@ function DashboardActions({
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
handle.enter,
|
||||
handleEnterFullScreen,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -258,7 +293,7 @@ function DashboardActions({
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={isLocked}
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
onClick={handleOpenSettings}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
@@ -283,7 +318,7 @@ function DashboardActions({
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={(): void => setIsJsonEditorOpen(true)}
|
||||
onClick={handleOpenJsonEditor}
|
||||
size="md"
|
||||
>
|
||||
JSON
|
||||
|
||||
@@ -12,8 +12,10 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { linkifyText } from 'utils/linkifyText';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -61,6 +63,7 @@ function DashboardInfo({
|
||||
onCancel,
|
||||
}: DashboardInfoProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
const hasTags = tags.length > 0;
|
||||
const hasDescription = !isEmpty(description);
|
||||
@@ -98,6 +101,14 @@ function DashboardInfo({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPublicUrl = (): void => {
|
||||
void logEvent(DashboardDetailEvents.PublicUrlOpened, {
|
||||
dashboardId,
|
||||
dashboardName: title,
|
||||
});
|
||||
openInNewTab(publicUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
@@ -182,7 +193,7 @@ function DashboardInfo({
|
||||
className={styles.publicLink}
|
||||
aria-label="Open public dashboard"
|
||||
testId="dashboard-public-link"
|
||||
onClick={(): void => openInNewTab(publicUrl)}
|
||||
onClick={handleOpenPublicUrl}
|
||||
>
|
||||
<Globe size={14} />
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,9 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { Drawer } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
@@ -51,9 +53,17 @@ function JsonEditorDrawer({
|
||||
const onCopy = useCallback((): void => {
|
||||
copyToClipboard(draft);
|
||||
toast.success('JSON copied to clipboard');
|
||||
}, [copyToClipboard, draft]);
|
||||
void logEvent(DashboardDetailEvents.JsonEditorAction, {
|
||||
action: 'copy',
|
||||
dashboardId: dashboard.id,
|
||||
});
|
||||
}, [copyToClipboard, draft, dashboard.id]);
|
||||
|
||||
const onDownload = useCallback((): void => {
|
||||
void logEvent(DashboardDetailEvents.JsonEditorAction, {
|
||||
action: 'download',
|
||||
dashboardId: dashboard.id,
|
||||
});
|
||||
const blob = new Blob([draft], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
@@ -63,7 +73,7 @@ function JsonEditorDrawer({
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [draft, dashboard.name]);
|
||||
}, [draft, dashboard.name, dashboard.id]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { updateDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTO,
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
@@ -140,14 +142,22 @@ export function useJsonEditor({
|
||||
const format = useCallback((): void => {
|
||||
try {
|
||||
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
|
||||
void logEvent(DashboardDetailEvents.JsonEditorAction, {
|
||||
action: 'format',
|
||||
dashboardId,
|
||||
});
|
||||
} catch {
|
||||
// Leave the draft untouched when it can't be parsed.
|
||||
}
|
||||
}, [draft]);
|
||||
}, [draft, dashboardId]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setDraft(appliedText);
|
||||
}, [appliedText]);
|
||||
void logEvent(DashboardDetailEvents.JsonEditorAction, {
|
||||
action: 'reset',
|
||||
dashboardId,
|
||||
});
|
||||
}, [appliedText, dashboardId]);
|
||||
|
||||
const apply = useCallback(async (): Promise<void> => {
|
||||
if (readOnly || !validity.valid || !isDirty) {
|
||||
@@ -163,6 +173,10 @@ export function useJsonEditor({
|
||||
dashboardToUpdatable({ ...dashboard, ...edited }),
|
||||
);
|
||||
toast.success('Dashboard updated');
|
||||
void logEvent(DashboardDetailEvents.JsonEditorAction, {
|
||||
action: 'apply',
|
||||
dashboardId,
|
||||
});
|
||||
refetch();
|
||||
onApplied();
|
||||
} catch (error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -87,38 +88,47 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
|
||||
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
|
||||
|
||||
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const next = !isDashboardLocked;
|
||||
setIsDashboardLocked(next);
|
||||
if (next) {
|
||||
setShowLockToggle(true);
|
||||
}
|
||||
try {
|
||||
const handleLockDashboardToggle = useCallback(
|
||||
async (source: 'menu' | 'header'): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const next = !isDashboardLocked;
|
||||
setIsDashboardLocked(next);
|
||||
if (next) {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
} else {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
setShowLockToggle(true);
|
||||
}
|
||||
// Patch just the `locked` flag in the cache — a full refetch would reload
|
||||
// every panel's chart data for a metadata-only change.
|
||||
const key = getGetDashboardV2QueryKey({ id });
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
|
||||
if (cached) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(key, {
|
||||
...cached,
|
||||
data: { ...cached.data, locked: next },
|
||||
try {
|
||||
if (next) {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
} else {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
}
|
||||
// Patch just the `locked` flag in the cache — a full refetch would reload
|
||||
// every panel's chart data for a metadata-only change.
|
||||
const key = getGetDashboardV2QueryKey({ id });
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
|
||||
if (cached) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(key, {
|
||||
...cached,
|
||||
data: { ...cached.data, locked: next },
|
||||
});
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.LockToggled, {
|
||||
dashboardId: id,
|
||||
dashboardName: title,
|
||||
locked: next,
|
||||
source,
|
||||
});
|
||||
} catch (error) {
|
||||
setIsDashboardLocked(!next);
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsDashboardLocked(!next);
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [id, isDashboardLocked, queryClient, showErrorModal]);
|
||||
},
|
||||
[id, title, isDashboardLocked, queryClient, showErrorModal],
|
||||
);
|
||||
|
||||
const onNameSave = useCallback(
|
||||
async (next: string): Promise<void> => {
|
||||
@@ -135,6 +145,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
];
|
||||
await patchAsync(patch);
|
||||
toast.success('Dashboard renamed successfully');
|
||||
void logEvent(DashboardDetailEvents.Renamed, {
|
||||
dashboardId: id,
|
||||
dashboardName: next,
|
||||
source: 'inline',
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
@@ -167,7 +182,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
publicUrl={publicUrl}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={canToggleLock ? handleLockDashboardToggle : undefined}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => void handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
@@ -182,7 +201,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isAuthor={isAuthor}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={handleLockDashboardToggle}
|
||||
onLockToggle={(): void => void handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import {
|
||||
@@ -26,6 +27,22 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
|
||||
useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
const handleCursorSyncChange = (value: DashboardCursorSync): void => {
|
||||
void logEvent(DashboardDetailEvents.CursorSyncChanged, {
|
||||
mode: value,
|
||||
dashboardId,
|
||||
});
|
||||
setCursorSyncMode(value);
|
||||
};
|
||||
|
||||
const handleTooltipFilterModeChange = (value: SyncTooltipFilterMode): void => {
|
||||
void logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: value,
|
||||
});
|
||||
setSyncTooltipFilterMode(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(settingsStyles.settingsCard, styles.crossPanelSyncGroup)}>
|
||||
<div className={styles.crossPanelSyncSectionHeader}>
|
||||
@@ -72,7 +89,7 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
<SegmentedControl
|
||||
testId="cursor-sync-mode"
|
||||
value={cursorSyncMode}
|
||||
onChange={setCursorSyncMode}
|
||||
onChange={handleCursorSyncChange}
|
||||
options={[
|
||||
{ label: 'No Sync', value: DashboardCursorSync.None },
|
||||
{ label: 'Crosshair', value: DashboardCursorSync.Crosshair },
|
||||
@@ -96,13 +113,7 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
<SegmentedControl
|
||||
testId="sync-tooltip-filter-mode"
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(value): void => {
|
||||
void logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: value,
|
||||
});
|
||||
setSyncTooltipFilterMode(value);
|
||||
}}
|
||||
onChange={handleTooltipFilterModeChange}
|
||||
options={[
|
||||
{ label: 'All', value: SyncTooltipFilterMode.All },
|
||||
{ label: 'Filtered', value: SyncTooltipFilterMode.Filtered },
|
||||
|
||||
@@ -5,7 +5,9 @@ import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -122,12 +124,13 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
setIsSaving(true);
|
||||
await patchAsync(ops);
|
||||
toast.success('Dashboard updated');
|
||||
void logEvent(DashboardDetailEvents.OverviewSaved, { dashboardId: id });
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [buildPatch, patchAsync, showErrorModal]);
|
||||
}, [buildPatch, patchAsync, showErrorModal, id]);
|
||||
|
||||
useEffect(() => {
|
||||
let numberOfUnsavedChanges = 0;
|
||||
@@ -160,7 +163,8 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
setUpdatedDescription(description);
|
||||
}, [title, image, tagsAsStrings, description]);
|
||||
void logEvent(DashboardDetailEvents.OverviewDiscarded, { dashboardId: id });
|
||||
}, [title, image, tagsAsStrings, description, id]);
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
invalidateGetPublicDashboard,
|
||||
useCreatePublicDashboard,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
useUpdatePublicDashboard,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -131,6 +133,11 @@ export function usePublicDashboard(
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.PublicDashboardAction, {
|
||||
action: 'publish',
|
||||
timeRangeEnabled,
|
||||
dashboardId,
|
||||
});
|
||||
createPublicDashboard({
|
||||
pathParams: { id: dashboardId },
|
||||
data: { timeRangeEnabled, defaultTimeRange },
|
||||
@@ -141,6 +148,10 @@ export function usePublicDashboard(
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.PublicDashboardAction, {
|
||||
action: 'update',
|
||||
dashboardId,
|
||||
});
|
||||
updatePublicDashboard({
|
||||
pathParams: { id: dashboardId },
|
||||
data: { timeRangeEnabled, defaultTimeRange },
|
||||
@@ -151,6 +162,10 @@ export function usePublicDashboard(
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.PublicDashboardAction, {
|
||||
action: 'unpublish',
|
||||
dashboardId,
|
||||
});
|
||||
deletePublicDashboard({ pathParams: { id: dashboardId } });
|
||||
}, [deletePublicDashboard, dashboardId]);
|
||||
|
||||
@@ -160,13 +175,21 @@ export function usePublicDashboard(
|
||||
}
|
||||
copyToClipboard(publicUrl);
|
||||
toast.success('Copied public dashboard URL successfully');
|
||||
}, [copyToClipboard, publicUrl]);
|
||||
void logEvent(DashboardDetailEvents.PublicDashboardAction, {
|
||||
action: 'copyUrl',
|
||||
dashboardId,
|
||||
});
|
||||
}, [copyToClipboard, publicUrl, dashboardId]);
|
||||
|
||||
const onOpenUrl = useCallback((): void => {
|
||||
if (publicUrl) {
|
||||
openInNewTab(publicUrl);
|
||||
void logEvent(DashboardDetailEvents.PublicDashboardAction, {
|
||||
action: 'openUrl',
|
||||
dashboardId,
|
||||
});
|
||||
}
|
||||
}, [publicUrl]);
|
||||
}, [publicUrl, dashboardId]);
|
||||
|
||||
const isLoading =
|
||||
isLoadingMeta || isFetching || isPublishing || isUpdating || isUnpublishing;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import Editor from 'components/Editor';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
|
||||
|
||||
import styles from './VariableForm.module.scss';
|
||||
@@ -29,10 +31,12 @@ function QueryVariableFields({
|
||||
const runTest = async (): Promise<void> => {
|
||||
setIsRunning(true);
|
||||
onError(null);
|
||||
let success = false;
|
||||
try {
|
||||
const res = await dashboardVariablesQuery({ query: queryValue, variables });
|
||||
if (res.statusCode === 200 && res.payload) {
|
||||
onPreview(res.payload.variableValues ?? []);
|
||||
success = true;
|
||||
} else {
|
||||
onError(res.error || 'Failed to run query');
|
||||
onPreview([]);
|
||||
@@ -48,6 +52,10 @@ function QueryVariableFields({
|
||||
onPreview([]);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
void logEvent(DashboardDetailEvents.VariableQueryTested, {
|
||||
variableType: 'query',
|
||||
success,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
|
||||
|
||||
import type { VariableSelectionMap } from '../../../VariablesBar/selectionTypes';
|
||||
@@ -7,6 +9,7 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { detectVariableCycle } from '../utils/variableCycleDetection';
|
||||
import {
|
||||
sortValuesByOrder,
|
||||
VARIABLE_TYPE_EVENT_LABEL,
|
||||
type VariableFormModel,
|
||||
type VariableType,
|
||||
} from '../variableFormModel';
|
||||
@@ -167,6 +170,13 @@ export function useVariableForm({
|
||||
return;
|
||||
}
|
||||
setCycleError(null);
|
||||
void logEvent(DashboardDetailEvents.VariableSaved, {
|
||||
variableType: VARIABLE_TYPE_EVENT_LABEL[next.type],
|
||||
multiSelect: next.multiSelect,
|
||||
hasAllOption: next.showAllOption,
|
||||
isNew,
|
||||
dashboardId,
|
||||
});
|
||||
onSave(next);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,14 +4,20 @@ import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { buildSyncVariableToPanelsPatch } from '../utils/applyVariableToPanelsPatch';
|
||||
import type { VariableFormModel } from '../variableFormModel';
|
||||
import {
|
||||
VARIABLE_TYPE_EVENT_LABEL,
|
||||
type VariableFormModel,
|
||||
} from '../variableFormModel';
|
||||
import {
|
||||
applyVariableQueryEdits,
|
||||
buildVariableImpactPatch,
|
||||
@@ -75,6 +81,7 @@ export function useVariableListActions({
|
||||
save,
|
||||
patchAsync,
|
||||
}: UseVariableListActionsParams): UseVariableListActions {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
@@ -165,16 +172,27 @@ export function useVariableListActions({
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
persist(next);
|
||||
void logEvent(DashboardDetailEvents.VariableReordered, {
|
||||
variableType: VARIABLE_TYPE_EVENT_LABEL[moved.type],
|
||||
fromIndex: from,
|
||||
toIndex: to,
|
||||
dashboardId,
|
||||
});
|
||||
},
|
||||
[persist, variables],
|
||||
[dashboardId, persist, variables],
|
||||
);
|
||||
|
||||
const handleConfirmDelete = useCallback(
|
||||
(index: number): void => {
|
||||
void logEvent(DashboardDetailEvents.VariableDeleted, {
|
||||
variableType: VARIABLE_TYPE_EVENT_LABEL[variables[index].type],
|
||||
hadReferences: false,
|
||||
dashboardId,
|
||||
});
|
||||
persist(variables.filter((_, i) => i !== index));
|
||||
setConfirmDeleteIndex(null);
|
||||
},
|
||||
[persist, variables],
|
||||
[dashboardId, persist, variables],
|
||||
);
|
||||
|
||||
// Delete requested from the list: if the variable is referenced anywhere, block
|
||||
@@ -224,6 +242,16 @@ export function useVariableListActions({
|
||||
? `Renamed to $${impact.newName}`
|
||||
: `Deleted $${impact.variableName}`,
|
||||
);
|
||||
if (impact.mode === 'delete') {
|
||||
const deleted = variables.find((v) => v.name === impact.variableName);
|
||||
void logEvent(DashboardDetailEvents.VariableDeleted, {
|
||||
variableType: deleted
|
||||
? VARIABLE_TYPE_EVENT_LABEL[deleted.type]
|
||||
: undefined,
|
||||
hadReferences: true,
|
||||
dashboardId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
impact.mode === 'rename'
|
||||
@@ -233,7 +261,7 @@ export function useVariableListActions({
|
||||
}
|
||||
setImpact(null);
|
||||
},
|
||||
[dashboard, impact, patchAsync, setVariables],
|
||||
[dashboard, dashboardId, impact, patchAsync, setVariables, variables],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import settingsStyles from '../DashboardSettings.module.scss';
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
@@ -32,6 +34,7 @@ interface VariablesSettingsProps {
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
// The drawer destroys on close, so reading this once on mount is enough to
|
||||
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
|
||||
const openAddOnMount = useDashboardStore(
|
||||
@@ -133,6 +136,10 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
toast.success(`Applied $${applyToAllVariable.name} to all panels`);
|
||||
void logEvent(DashboardDetailEvents.ApplyToAllConfirmed, {
|
||||
variableType: 'dynamic',
|
||||
dashboardId,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Could not apply the variable to panels');
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ import { sortBy } from 'lodash-es';
|
||||
*/
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
/** Analytics label for each variable type (TEXT is surfaced as "textbox"). */
|
||||
export const VARIABLE_TYPE_EVENT_LABEL: Record<VariableType, string> = {
|
||||
QUERY: 'query',
|
||||
CUSTOM: 'custom',
|
||||
TEXT: 'textbox',
|
||||
DYNAMIC: 'dynamic',
|
||||
};
|
||||
|
||||
/** Telemetry signal — the generated enum (traces / logs / metrics). */
|
||||
// A query/variable signal is only logs/traces/metrics. TelemetrytypesSignalDTO
|
||||
// also carries the empty "any" value used on field keys, which is not a valid
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import styles from './Header.module.scss';
|
||||
@@ -32,7 +34,13 @@ function Header({
|
||||
onClose,
|
||||
}: HeaderProps): JSX.Element {
|
||||
const discard = useConfirmableAction(
|
||||
useCallback(async (): Promise<void> => onClose(), [onClose]),
|
||||
useCallback(async (): Promise<void> => {
|
||||
// Only reachable after confirming a discard, which is gated on unsaved edits.
|
||||
void logEvent(DashboardDetailEvents.PanelEditorDiscarded, {
|
||||
wasDirty: true,
|
||||
});
|
||||
onClose();
|
||||
}, [onClose]),
|
||||
);
|
||||
|
||||
// Confirm before closing with unsaved edits; a pristine panel closes straight away.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
type DashboardtypesJSONPatchOperationDTO,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
DashboardtypesPatchOpDTO,
|
||||
type GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { createPanelOps } from '../../patchOps';
|
||||
@@ -73,6 +75,12 @@ export function usePanelEditorSave({
|
||||
|
||||
// Optimistic cache write + settle refetch (replaces the manual invalidate).
|
||||
await patchAsync(ops);
|
||||
void logEvent(DashboardDetailEvents.PanelEditorSaved, {
|
||||
panelType: spec.plugin.kind,
|
||||
isNew,
|
||||
dashboardId,
|
||||
panelId: savedPanelId,
|
||||
});
|
||||
return savedPanelId;
|
||||
},
|
||||
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type {
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
type PartialPanelTypes,
|
||||
} from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import type {
|
||||
OrderByPayload,
|
||||
Query,
|
||||
@@ -96,6 +98,10 @@ export function usePanelTypeSwitch({
|
||||
if (newKind === oldKind) {
|
||||
return;
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.PanelTypeChanged, {
|
||||
from: oldKind,
|
||||
to: newKind,
|
||||
});
|
||||
const query = queryRef.current;
|
||||
|
||||
cacheRef.current.set(oldKind, {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import { panelHasFixedTimePreference } from '../../../hooks/resolvePanelTimeWindow';
|
||||
import {
|
||||
selectViewPanelExtendWindow,
|
||||
useViewPanelStore,
|
||||
} from '../../../store/useViewPanelStore';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../../types/panelKind';
|
||||
import PanelLoader from '../PanelLoader/PanelLoader';
|
||||
import PanelMessage, { PanelMessageAction } from '../PanelMessage/PanelMessage';
|
||||
import { useExtendTimeWindow } from './useExtendTimeWindow';
|
||||
@@ -50,17 +53,37 @@ function NoData({
|
||||
return <PanelLoader />;
|
||||
}
|
||||
|
||||
const panelType = panel
|
||||
? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind]
|
||||
: undefined;
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
activeExtend?.canExtend && activeExtend.actionLabel
|
||||
? {
|
||||
label: activeExtend.actionLabel,
|
||||
onClick: activeExtend.extend,
|
||||
onClick: (): void => {
|
||||
void logEvent(DashboardDetailEvents.NoDataAction, {
|
||||
action: 'extendTime',
|
||||
panelType,
|
||||
});
|
||||
activeExtend.extend();
|
||||
},
|
||||
icon: <CalendarRange size={14} />,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const retryAction: PanelMessageAction | undefined = onRetry
|
||||
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
|
||||
? {
|
||||
label: 'Retry',
|
||||
onClick: (): void => {
|
||||
void logEvent(DashboardDetailEvents.NoDataAction, {
|
||||
action: 'retry',
|
||||
panelType,
|
||||
});
|
||||
onRetry();
|
||||
},
|
||||
icon: <RotateCw size={14} />,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
Loader,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getAggregateColumnHeader } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
import { getDataLinks } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/getDataLinks';
|
||||
import { resolvePanelContextLinks } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/resolvePanelContextLinks';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -72,6 +74,20 @@ function DrilldownAggregateMenu({
|
||||
[context.filters],
|
||||
);
|
||||
|
||||
const handleViewLogs = (): void => {
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'viewLogs',
|
||||
});
|
||||
onViewLogs();
|
||||
};
|
||||
|
||||
const handleViewTraces = (): void => {
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'viewTraces',
|
||||
});
|
||||
onViewTraces();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Header>
|
||||
@@ -104,7 +120,7 @@ function DrilldownAggregateMenu({
|
||||
</span>
|
||||
)
|
||||
}
|
||||
onClick={onViewLogs}
|
||||
onClick={handleViewLogs}
|
||||
disabled={isResolving}
|
||||
>
|
||||
<span data-testid="drilldown-view-logs">View in Logs</span>
|
||||
@@ -119,7 +135,7 @@ function DrilldownAggregateMenu({
|
||||
</span>
|
||||
)
|
||||
}
|
||||
onClick={onViewTraces}
|
||||
onClick={handleViewTraces}
|
||||
disabled={isResolving}
|
||||
>
|
||||
<span data-testid="drilldown-view-traces">View in Traces</span>
|
||||
@@ -139,6 +155,9 @@ function DrilldownAggregateMenu({
|
||||
key={link.id}
|
||||
icon={<Link size={16} color={context.seriesColor} />}
|
||||
onClick={(): void => {
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'contextLink',
|
||||
});
|
||||
openInNewTab(link.url);
|
||||
onClose();
|
||||
}}
|
||||
@@ -151,6 +170,9 @@ function DrilldownAggregateMenu({
|
||||
key={link.id}
|
||||
icon={<Link size={16} color={context.seriesColor} />}
|
||||
onClick={(): void => {
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'contextLink',
|
||||
});
|
||||
openInNewTab(link.url);
|
||||
onClose();
|
||||
}}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Search, X } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import styles from './PanelHeaderSearch.module.scss';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
@@ -34,6 +36,11 @@ function PanelHeaderSearch({
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
onChange(e.target.value);
|
||||
void logEvent(DashboardDetailEvents.PanelSearched, {}, 'track', true);
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<TooltipSimple title="Search" arrow>
|
||||
@@ -76,9 +83,7 @@ function PanelHeaderSearch({
|
||||
<X size={14} />
|
||||
</Button>
|
||||
}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
onChange(e.target.value)
|
||||
}
|
||||
onChange={handleSearchChange}
|
||||
onBlur={collapseIfEmpty}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Escape') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
@@ -17,7 +17,10 @@ import { useViewPanelMode } from './useViewPanelMode';
|
||||
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import {
|
||||
DashboardDetailEvents,
|
||||
DashboardEvents,
|
||||
} from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
interface ViewPanelModalContentProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
@@ -85,6 +88,21 @@ function ViewPanelModalContent({
|
||||
);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// Modal drag-to-zoom is its own path (local window, not the grid's) — tag it distinctly.
|
||||
const handleDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
if (Math.floor(start) !== Math.floor(end)) {
|
||||
void logEvent(DashboardDetailEvents.PanelZoomed, {
|
||||
context: 'viewModal',
|
||||
panelType: draft.spec.plugin.kind,
|
||||
panelId,
|
||||
});
|
||||
}
|
||||
onDragSelect(start, end);
|
||||
},
|
||||
[onDragSelect, draft.spec.plugin.kind, panelId],
|
||||
);
|
||||
|
||||
// Publish the modal's local extender for the nested no-data state; cleared on close.
|
||||
const setViewPanelExtendWindow = useViewPanelStore(
|
||||
(s) => s.setViewPanelExtendWindow,
|
||||
@@ -162,7 +180,7 @@ function ViewPanelModalContent({
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
onDragSelect={handleDragSelect}
|
||||
pagination={pagination}
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
dashboardPreference={isolatedPreference}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
@@ -75,6 +78,12 @@ export function useClonePanel({
|
||||
// rejection (the optimistic cache write + settle refetch handle state).
|
||||
try {
|
||||
await clone;
|
||||
void logEvent(DashboardDetailEvents.PanelAction, {
|
||||
action: 'clone',
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[source.panel.spec.plugin.kind],
|
||||
panelId,
|
||||
dashboardId,
|
||||
});
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -38,12 +41,21 @@ export function useDeletePanel({
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = section.items.find((i) => i.id === panelId);
|
||||
const nextItems = section.items.filter((i) => i.id !== panelId);
|
||||
try {
|
||||
await patchAsync([
|
||||
replaceSectionItemsOp(layoutIndex, nextItems),
|
||||
removePanelOp(panelId),
|
||||
]);
|
||||
void logEvent(DashboardDetailEvents.PanelAction, {
|
||||
action: 'delete',
|
||||
panelType: removed?.panel
|
||||
? PANEL_KIND_TO_PANEL_TYPE[removed.panel.spec.plugin.kind]
|
||||
: undefined,
|
||||
panelId,
|
||||
dashboardId,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { getTableCsvRows } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/TablePanel/tableCsv';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { PanelOfKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { downloadCsv } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/downloadCsv';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
@@ -37,5 +40,9 @@ export function useDownloadPanelCsv({
|
||||
return;
|
||||
}
|
||||
downloadCsv(rows, fileName);
|
||||
void logEvent(DashboardDetailEvents.PanelExported, {
|
||||
format: 'csv',
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
});
|
||||
}, [canDownloadCsv, fileName, panel, data]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import {
|
||||
downloadElementAsImage,
|
||||
@@ -37,6 +39,10 @@ export function useDownloadPanelImage(): UseDownloadPanelImage {
|
||||
}
|
||||
try {
|
||||
await downloadElementAsImage(node, panelName, format);
|
||||
void logEvent(DashboardDetailEvents.PanelExported, {
|
||||
format,
|
||||
panelId,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Could not download panel.', {
|
||||
action: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useBaseDrilldownNavigate from 'container/QueryTable/Drilldown/useBaseDrilldownNavigate';
|
||||
@@ -18,6 +19,7 @@ import { buildAggregateData } from 'pages/DashboardPageV2/DashboardContainer/Pan
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import DrilldownAggregateMenu from '../DrilldownMenu/DrilldownAggregateMenu';
|
||||
@@ -125,10 +127,11 @@ export function useDrilldown(
|
||||
|
||||
const onPanelClick = useCallback(
|
||||
(payload: DrilldownClickPayload): void => {
|
||||
void logEvent(DashboardDetailEvents.DrilldownOpened, { panelType });
|
||||
setSubMenu(DrilldownSubMenu.Base);
|
||||
onClick(payload.coordinates, payload.context);
|
||||
},
|
||||
[onClick],
|
||||
[onClick, panelType],
|
||||
);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getQueryData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import {
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
} from 'container/QueryTable/Drilldown/tableDrilldownUtils';
|
||||
import type { BreakoutAttributeType } from 'container/QueryTable/Drilldown/types';
|
||||
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import type {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
@@ -54,6 +56,10 @@ export function useDrilldownBreakout({
|
||||
if (!aggregateData) {
|
||||
return;
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'breakout',
|
||||
panelType,
|
||||
});
|
||||
const breakoutQuery = getBreakoutQuery(
|
||||
v1Query,
|
||||
aggregateData,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import { useOptimisticPatch } from 'pages/DashboardPageV2/DashboardContainer/hoo
|
||||
import { selectVariableValues } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import type { VariableSelection } from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/selectionTypes';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
interface UseDrilldownDashboardVariablesArgs {
|
||||
/** Group-by field filters from the clicked point (empty when the click has no group-by). */
|
||||
@@ -98,6 +100,10 @@ export function useDrilldownDashboardVariables({
|
||||
toast.error(`Variable "${fieldName}" already exists`);
|
||||
return;
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'createVariable',
|
||||
dashboardId,
|
||||
});
|
||||
const model: VariableFormModel = {
|
||||
...emptyVariableFormModel(),
|
||||
name: fieldName,
|
||||
@@ -120,7 +126,15 @@ export function useDrilldownDashboardVariables({
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[existingNames, signal, variables, patchAsync, setSelection, onClose],
|
||||
[
|
||||
existingNames,
|
||||
signal,
|
||||
variables,
|
||||
patchAsync,
|
||||
setSelection,
|
||||
onClose,
|
||||
dashboardId,
|
||||
],
|
||||
);
|
||||
|
||||
const actions = useMemo<DrilldownVariableAction[]>(
|
||||
@@ -156,6 +170,10 @@ export function useDrilldownDashboardVariables({
|
||||
? DrilldownVariableActionKind.Unset
|
||||
: DrilldownVariableActionKind.Set,
|
||||
onClick: (): void => {
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'setVariable',
|
||||
dashboardId,
|
||||
});
|
||||
setSelection(existing.name, {
|
||||
value: isSame ? cleared : assigned,
|
||||
allSelected: false,
|
||||
@@ -171,6 +189,7 @@ export function useDrilldownDashboardVariables({
|
||||
setSelection,
|
||||
handleCreate,
|
||||
onClose,
|
||||
dashboardId,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
addFilterToQuery,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
isNumberDataType,
|
||||
} from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface UseDrilldownFilterArgs {
|
||||
@@ -59,6 +61,10 @@ export function useDrilldownFilter({
|
||||
const refinedQuery = addFilterToQuery(v1Query, [
|
||||
{ filterKey: context.clickedKey, filterValue, operator },
|
||||
]);
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'filterByValue',
|
||||
panelType,
|
||||
});
|
||||
openViewWithQuery(panelId, refinedQuery, panelType);
|
||||
onClose();
|
||||
},
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -66,6 +69,14 @@ export function useMovePanelToSection({
|
||||
targetItems,
|
||||
}),
|
||||
);
|
||||
void logEvent(DashboardDetailEvents.PanelAction, {
|
||||
action: 'move',
|
||||
panelType: moved.panel
|
||||
? PANEL_KIND_TO_PANEL_TYPE[moved.panel.spec.plugin.kind]
|
||||
: undefined,
|
||||
panelId,
|
||||
dashboardId,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSy
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
|
||||
export interface PanelInteractions {
|
||||
@@ -51,6 +53,7 @@ export function usePanelInteractions(): PanelInteractions {
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
void logEvent(DashboardDetailEvents.PanelZoomed, { context: 'panel' });
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, safeNavigate, urlQuery],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
|
||||
@@ -48,6 +50,7 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
clearViewPanelHandoff();
|
||||
void logEvent(DashboardDetailEvents.PanelViewed, { panelId });
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -48,6 +50,10 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchAsync([op]);
|
||||
void logEvent(DashboardDetailEvents.SectionAction, {
|
||||
action: 'add',
|
||||
dashboardId,
|
||||
});
|
||||
// The new empty section is appended, so its layout index is the prior count;
|
||||
// key it the way `getSectionStableId` does so it reveals itself on render.
|
||||
const newIndex = isFirstSection ? 0 : layouts.length;
|
||||
|
||||
@@ -3,6 +3,9 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { cloneSectionOps } from '../../../patchOps';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
@@ -46,6 +49,10 @@ export function useCloneSection(): (
|
||||
|
||||
try {
|
||||
await clone;
|
||||
void logEvent(DashboardDetailEvents.SectionAction, {
|
||||
action: 'clone',
|
||||
panelCount: panels.length,
|
||||
});
|
||||
} catch {
|
||||
// toast.promise owns the error UX; the optimistic write + settle handle state.
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -39,6 +41,11 @@ export function useDeleteSection({ section }: Params): Result {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchAsync(ops);
|
||||
void logEvent(DashboardDetailEvents.SectionAction, {
|
||||
action: 'delete',
|
||||
panelCount: section.items.length,
|
||||
dashboardId,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -50,6 +52,9 @@ export function useFirstSectionMigration({ sections }: Params): Result {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchAsync(ops);
|
||||
void logEvent(DashboardDetailEvents.FirstSectionMigrationConfirmed, {
|
||||
dashboardId,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { Layout } from 'react-grid-layout';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -78,6 +80,9 @@ export function usePersistLayout({ layoutIndex, items }: Params): Result {
|
||||
if (!hasGeometryChanged(nextItems, items)) {
|
||||
return;
|
||||
}
|
||||
// High-frequency (drag/resize stop) — rate-limited. Single handler can't tell
|
||||
// drag from resize, so changeType is omitted.
|
||||
void logEvent(DashboardDetailEvents.LayoutChanged, {}, 'track', true);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchAsync([replaceSectionItemsOp(layoutIndex, nextItems)]);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -32,6 +34,10 @@ export function useRenameSection({ layoutIndex }: Params): Result {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchAsync([renameSectionOp(layoutIndex, trimmed)]);
|
||||
void logEvent(DashboardDetailEvents.SectionAction, {
|
||||
action: 'rename',
|
||||
dashboardId,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -100,6 +102,10 @@ export function useSectionDragReorder({ sections, layouts }: Params): Result {
|
||||
|
||||
try {
|
||||
await patchAsync([reorderLayoutsOp(newLayouts)]);
|
||||
void logEvent(DashboardDetailEvents.SectionsReordered, {
|
||||
sectionCount: newOrdered.length,
|
||||
dashboardId,
|
||||
});
|
||||
} catch (error) {
|
||||
setLocalOrderIds(null); // revert optimistic order on failure
|
||||
showErrorModal(error as APIError);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import {
|
||||
selectIsSectionOpen,
|
||||
useDashboardStore,
|
||||
@@ -29,8 +32,13 @@ export function useToggleSectionCollapse({ sectionId }: Params): Result {
|
||||
const toggle = useCallback((): void => {
|
||||
if (dashboardId) {
|
||||
toggleSectionCollapse(dashboardId, sectionId);
|
||||
// `open` flips on toggle, so the resulting collapsed state is the current `open`.
|
||||
void logEvent(DashboardDetailEvents.SectionCollapsed, {
|
||||
collapsed: open,
|
||||
dashboardId,
|
||||
});
|
||||
}
|
||||
}, [dashboardId, sectionId, toggleSectionCollapse]);
|
||||
}, [dashboardId, sectionId, toggleSectionCollapse, open]);
|
||||
|
||||
return { open, toggle };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useRef, useState } from 'react';
|
||||
import type { InputRef } from 'antd';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
|
||||
import { Input } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import type { VariableSelection } from '../../selectionTypes';
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
@@ -30,7 +32,19 @@ function TextSelector({
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: string): void => onChange({ value: next, allSelected: false }),
|
||||
(next: string): void => {
|
||||
void logEvent(
|
||||
DashboardDetailEvents.VariableValueSelected,
|
||||
{
|
||||
variableType: 'textbox',
|
||||
multiSelect: false,
|
||||
selectionCount: next ? 1 : 0,
|
||||
},
|
||||
'track',
|
||||
true,
|
||||
);
|
||||
onChange({ value: next, allSelected: false });
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useMemo } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import type { OptionData } from 'components/NewSelect/types';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import type { VariableSelection } from '../../selectionTypes';
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
|
||||
interface ValueSelectorProps {
|
||||
options: string[];
|
||||
/** Analytics label for the variable type (query / custom / dynamic). */
|
||||
variableType: string;
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
loading?: boolean;
|
||||
@@ -25,6 +29,7 @@ interface ValueSelectorProps {
|
||||
*/
|
||||
function ValueSelector({
|
||||
options,
|
||||
variableType,
|
||||
multiSelect,
|
||||
showAllOption,
|
||||
loading,
|
||||
@@ -67,6 +72,12 @@ function ValueSelector({
|
||||
: next
|
||||
? [String(next)]
|
||||
: [];
|
||||
void logEvent(
|
||||
DashboardDetailEvents.VariableValueSelected,
|
||||
{ variableType, multiSelect: true, selectionCount: values.length },
|
||||
'track',
|
||||
true,
|
||||
);
|
||||
if (values.length === 0) {
|
||||
onChange({ value: [], allSelected: false });
|
||||
return;
|
||||
@@ -78,7 +89,12 @@ function ValueSelector({
|
||||
options.every((option) => values.includes(option));
|
||||
onChange({ value: values, allSelected: isAll });
|
||||
}}
|
||||
onClear={(): void => onChange({ value: [], allSelected: false })}
|
||||
onClear={(): void => {
|
||||
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
|
||||
variableType,
|
||||
});
|
||||
onChange({ value: [], allSelected: false });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -98,9 +114,15 @@ function ValueSelector({
|
||||
onRetry={onRetry}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
onChange={(next): void =>
|
||||
onChange({ value: next == null ? '' : String(next), allSelected: false })
|
||||
}
|
||||
onChange={(next): void => {
|
||||
void logEvent(
|
||||
DashboardDetailEvents.VariableValueSelected,
|
||||
{ variableType, multiSelect: false, selectionCount: next == null ? 0 : 1 },
|
||||
'track',
|
||||
true,
|
||||
);
|
||||
onChange({ value: next == null ? '' : String(next), allSelected: false });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { VariableFormModel } from '../../../DashboardSettings/Variables/variableFormModel';
|
||||
import {
|
||||
VARIABLE_TYPE_EVENT_LABEL,
|
||||
type VariableFormModel,
|
||||
} from '../../../DashboardSettings/Variables/variableFormModel';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
@@ -44,6 +47,7 @@ function VariableValueControl({
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
variableType={VARIABLE_TYPE_EVENT_LABEL[variable.type]}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={loading}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import {
|
||||
sortValuesByOrder,
|
||||
VARIABLE_TYPE_EVENT_LABEL,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from '../selectionTypes';
|
||||
import {
|
||||
@@ -36,6 +41,15 @@ export function useVariableOptions(
|
||||
[variable.type, variable.customValue, variable.sort],
|
||||
);
|
||||
|
||||
// One-shot per distinct fetch error (effect only re-runs when it changes).
|
||||
useEffect(() => {
|
||||
if (fetched.errorMessage) {
|
||||
void logEvent(DashboardDetailEvents.VariableOptionsFetchFailed, {
|
||||
variableType: VARIABLE_TYPE_EVENT_LABEL[variable.type],
|
||||
});
|
||||
}
|
||||
}, [fetched.errorMessage, variable.type]);
|
||||
|
||||
if (variable.type === 'CUSTOM') {
|
||||
return { options: customOptions, loading: false, errorMessage: null };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { RotateCcw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import styles from './DashboardChangedDialog.module.scss';
|
||||
|
||||
interface DashboardChangedDialogProps {
|
||||
@@ -15,12 +18,30 @@ function DashboardChangedDialog({
|
||||
onReload,
|
||||
onDismiss,
|
||||
}: DashboardChangedDialogProps): JSX.Element {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
const handleReload = (): void => {
|
||||
void logEvent(DashboardDetailEvents.StaleReloadPrompted, {
|
||||
dashboardId,
|
||||
action: 'reload',
|
||||
});
|
||||
onReload();
|
||||
};
|
||||
|
||||
const handleDismiss = (): void => {
|
||||
void logEvent(DashboardDetailEvents.StaleReloadPrompted, {
|
||||
dashboardId,
|
||||
action: 'dismiss',
|
||||
});
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onDismiss}
|
||||
onClick={handleDismiss}
|
||||
testId="dashboard-changed-dismiss"
|
||||
>
|
||||
Dismiss
|
||||
@@ -29,7 +50,7 @@ function DashboardChangedDialog({
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<RotateCcw size={12} />}
|
||||
onClick={onReload}
|
||||
onClick={handleReload}
|
||||
testId="dashboard-changed-reload"
|
||||
>
|
||||
Reload
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { useTimeSearchParams } from './useTimeSearchParams';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardDetailEvents } from '../../constants/events';
|
||||
|
||||
interface OpenPanelEditorOptions {
|
||||
handoffState?: PanelEditorHandoffState;
|
||||
@@ -24,6 +26,11 @@ export function useOpenPanelEditor(): (
|
||||
|
||||
return useCallback(
|
||||
(panelId: string, options?: OpenPanelEditorOptions): void => {
|
||||
void logEvent(DashboardDetailEvents.PanelAction, {
|
||||
action: 'edit',
|
||||
panelId,
|
||||
dashboardId,
|
||||
});
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import DashboardContainer from './DashboardContainer';
|
||||
import { useDashboardFetch } from './DashboardContainer/hooks/useDashboardFetch';
|
||||
@@ -13,6 +16,23 @@ function DashboardPageV2(): JSX.Element {
|
||||
const { dashboard, isLoading, isError, error, refetch } =
|
||||
useDashboardFetch(dashboardId);
|
||||
|
||||
// Fire once per dashboard load (re-fires on navigating to a different id).
|
||||
const openedRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!dashboard || openedRef.current === dashboard.id) {
|
||||
return;
|
||||
}
|
||||
openedRef.current = dashboard.id;
|
||||
const { spec } = dashboard;
|
||||
void logEvent(DashboardDetailEvents.Opened, {
|
||||
dashboardId: dashboard.id,
|
||||
dashboardName: spec.display.name,
|
||||
panelCount: Object.keys(spec.panels).length,
|
||||
variableCount: spec.variables.length,
|
||||
sectionCount: spec.layouts.length,
|
||||
});
|
||||
}, [dashboard]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
@@ -2,3 +2,69 @@ export enum DashboardEvents {
|
||||
SWITCH_TO_EDIT_MODE = 'View Panel: Switch to edit mode',
|
||||
SWITCH_TO_VIEW_MODE = 'Edit Panel: Switch to view mode',
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics events for the V2 dashboard detail page.
|
||||
* Fire via `logEvent(DashboardDetailEvents.<X>, { ...camelCaseProps })`.
|
||||
*/
|
||||
export enum DashboardDetailEvents {
|
||||
// Lifecycle
|
||||
Opened = 'Dashboard Detail V2: Opened',
|
||||
PanelDataFetched = 'Dashboard Detail V2: Panel data fetched',
|
||||
Renamed = 'Dashboard Detail V2: Dashboard renamed',
|
||||
Cloned = 'Dashboard Detail V2: Dashboard cloned',
|
||||
Deleted = 'Dashboard Detail V2: Dashboard deleted',
|
||||
LockToggled = 'Dashboard Detail V2: Lock toggled',
|
||||
FullScreenToggled = 'Dashboard Detail V2: Full screen toggled',
|
||||
StaleReloadPrompted = 'Dashboard Detail V2: Stale reload prompted',
|
||||
PublicUrlOpened = 'Dashboard Detail V2: Public URL opened',
|
||||
|
||||
// Sections
|
||||
SectionAction = 'Dashboard Detail V2: Section action',
|
||||
SectionCollapsed = 'Dashboard Detail V2: Section collapsed',
|
||||
SectionsReordered = 'Dashboard Detail V2: Sections reordered',
|
||||
FirstSectionMigrationConfirmed = 'Dashboard Detail V2: First section migration confirmed',
|
||||
|
||||
// Panels
|
||||
PanelAdded = 'Dashboard Detail V2: Panel added',
|
||||
// Reuses the existing V1 string so panel actions stay comparable across versions.
|
||||
PanelAction = 'Dashboard Detail: Panel action',
|
||||
PanelExported = 'Dashboard Detail V2: Panel exported',
|
||||
PanelViewed = 'Dashboard Detail V2: Panel viewed',
|
||||
PanelEditorSaved = 'Dashboard Detail V2: Panel editor saved',
|
||||
PanelEditorDiscarded = 'Dashboard Detail V2: Panel editor discarded',
|
||||
PanelTypeChanged = 'Dashboard Detail V2: Panel type changed',
|
||||
PanelSearched = 'Dashboard Detail V2: Panel searched',
|
||||
NoDataAction = 'Dashboard Detail V2: No data action',
|
||||
|
||||
// Layout (high-frequency — fire rate-limited)
|
||||
LayoutChanged = 'Dashboard Detail V2: Layout changed',
|
||||
|
||||
// Variables — setup
|
||||
VariableSaved = 'Dashboard Detail V2: Variable saved',
|
||||
VariableDeleted = 'Dashboard Detail V2: Variable deleted',
|
||||
VariableReordered = 'Dashboard Detail V2: Variable reordered',
|
||||
VariableQueryTested = 'Dashboard Detail V2: Variable query tested',
|
||||
ApplyToAllConfirmed = 'Dashboard Detail V2: Apply to all confirmed',
|
||||
|
||||
// Variables — runtime selection
|
||||
VariableValueSelected = 'Dashboard Detail V2: Variable value selected',
|
||||
VariableMultiSelectCleared = 'Dashboard Detail V2: Variable multiselect cleared',
|
||||
VariableOptionsFetchFailed = 'Dashboard Detail V2: Variable options fetch failed',
|
||||
|
||||
// Drill-down
|
||||
DrilldownOpened = 'Dashboard Detail V2: Drilldown opened',
|
||||
DrilldownAction = 'Dashboard Detail V2: Drilldown action',
|
||||
PanelZoomed = 'Dashboard Detail V2: Panel zoomed',
|
||||
|
||||
// JSON editor
|
||||
JsonEditorOpened = 'Dashboard Detail V2: JSON editor opened',
|
||||
JsonEditorAction = 'Dashboard Detail V2: JSON editor action',
|
||||
|
||||
// Settings & sharing
|
||||
SettingsOpened = 'Dashboard Detail V2: Settings opened',
|
||||
OverviewSaved = 'Dashboard Detail V2: Overview saved',
|
||||
OverviewDiscarded = 'Dashboard Detail V2: Overview discarded',
|
||||
CursorSyncChanged = 'Dashboard Detail V2: Cursor sync changed',
|
||||
PublicDashboardAction = 'Dashboard Detail V2: Public dashboard action',
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Tag,
|
||||
} from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
cloneDashboardV2,
|
||||
invalidateListDashboardsForUserV2,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -75,6 +77,10 @@ function ActionsPopover({
|
||||
mutationFn: () => cloneDashboardV2({ id: dashboardId }),
|
||||
onSuccess: (response) => {
|
||||
toast.success(`Duplicated "${dashboardName}"`);
|
||||
void logEvent(DashboardListEvents.RowAction, {
|
||||
action: 'duplicate',
|
||||
dashboardId,
|
||||
});
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
@@ -99,6 +105,10 @@ function ActionsPopover({
|
||||
: lockDashboardV2({ id: dashboardId }),
|
||||
onSuccess: async () => {
|
||||
toast.success(isLocked ? 'Dashboard unlocked' : 'Dashboard locked');
|
||||
void logEvent(DashboardListEvents.RowAction, {
|
||||
action: isLocked ? 'unlock' : 'lock',
|
||||
dashboardId,
|
||||
});
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
@@ -106,6 +116,26 @@ function ActionsPopover({
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenInNewTab = (e: React.MouseEvent<HTMLElement>): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
openInNewTab(link);
|
||||
void logEvent(DashboardListEvents.RowAction, {
|
||||
action: 'openNewTab',
|
||||
dashboardId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyLink = (e: React.MouseEvent<HTMLElement>): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(getAbsoluteUrl(link));
|
||||
void logEvent(DashboardListEvents.RowAction, {
|
||||
action: 'copyLink',
|
||||
dashboardId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
@@ -129,11 +159,7 @@ function ActionsPopover({
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<SquareArrowOutUpRight size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
openInNewTab(link);
|
||||
}}
|
||||
onClick={handleOpenInNewTab}
|
||||
testId="dashboard-action-open-new-tab"
|
||||
>
|
||||
Open in New Tab
|
||||
@@ -142,11 +168,7 @@ function ActionsPopover({
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Link2 size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(getAbsoluteUrl(link));
|
||||
}}
|
||||
onClick={handleCopyLink}
|
||||
testId="dashboard-action-copy-link"
|
||||
>
|
||||
Copy Link
|
||||
|
||||
@@ -7,10 +7,12 @@ import { CircleAlert, Trash2 } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
deleteDashboardV2,
|
||||
invalidateListDashboardsForUserV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -50,6 +52,10 @@ function DeleteActionItem({
|
||||
toast.success(
|
||||
t('dashboard:delete_dashboard_success', { name: dashboardName }),
|
||||
);
|
||||
void logEvent(DashboardListEvents.RowAction, {
|
||||
action: 'delete',
|
||||
dashboardId,
|
||||
});
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
invalidateListDashboardsForUserV2,
|
||||
// eslint-disable-next-line no-restricted-imports -- list tag-edit targets another dashboard by id; useOptimisticPatch is bound to the open dashboard's store/cache.
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -63,6 +65,10 @@ function EditTagsModal({
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('Tags updated');
|
||||
void logEvent(DashboardListEvents.RowAction, {
|
||||
action: 'editTags',
|
||||
dashboardId,
|
||||
});
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
onClose();
|
||||
},
|
||||
|
||||
@@ -4,12 +4,14 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
invalidateListDashboardsForUserV2,
|
||||
// eslint-disable-next-line no-restricted-imports -- list rename targets another dashboard by id; useOptimisticPatch is bound to the open dashboard's store/cache.
|
||||
patchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -54,6 +56,10 @@ function RenameDashboardModal({
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('Dashboard renamed');
|
||||
void logEvent(DashboardListEvents.RowAction, {
|
||||
action: 'rename',
|
||||
dashboardId,
|
||||
});
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
onClose();
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/Gene
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
@@ -97,6 +98,10 @@ function DashboardRow({
|
||||
return;
|
||||
}
|
||||
togglePin(id, isPinned);
|
||||
void logEvent(DashboardListEvents.DashboardPinned, {
|
||||
pinned: !isPinned,
|
||||
dashboardId: id,
|
||||
});
|
||||
};
|
||||
|
||||
const pinLabel = isPinned ? 'Unpin dashboard' : 'Pin dashboard';
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
@@ -99,12 +100,17 @@ function DashboardsList(): JSX.Element {
|
||||
// View actions that change the result set reset pagination too.
|
||||
const handleSelectView = useCallback(
|
||||
(id: string): void => {
|
||||
void logEvent(DashboardListEvents.ViewSelected, {
|
||||
viewId: id,
|
||||
viewType: builtinViews.some((v) => v.id === id) ? 'builtin' : 'custom',
|
||||
});
|
||||
selectView(id);
|
||||
void setPage(1);
|
||||
},
|
||||
[selectView, setPage],
|
||||
[selectView, setPage, builtinViews],
|
||||
);
|
||||
const handleResetView = useCallback((): void => {
|
||||
void logEvent(DashboardListEvents.ViewReset, {});
|
||||
resetView();
|
||||
void setPage(1);
|
||||
}, [resetView, setPage]);
|
||||
@@ -116,6 +122,7 @@ function DashboardsList(): JSX.Element {
|
||||
[removeView, setPage],
|
||||
);
|
||||
const toggleRail = useCallback((): void => {
|
||||
void logEvent(DashboardListEvents.RailToggled, { collapsed: !railCollapsed });
|
||||
setRailCollapsed(!railCollapsed);
|
||||
}, [setRailCollapsed, railCollapsed]);
|
||||
|
||||
@@ -228,18 +235,23 @@ function DashboardsList(): JSX.Element {
|
||||
|
||||
const onSortChange = useCallback(
|
||||
(column: DashboardtypesListSortDTO): void => {
|
||||
void logEvent(DashboardListEvents.SortChanged, { column, order: sortOrder });
|
||||
void setSortColumn(column);
|
||||
void setPage(1);
|
||||
},
|
||||
[setSortColumn, setPage],
|
||||
[setSortColumn, setPage, sortOrder],
|
||||
);
|
||||
|
||||
const onOrderChange = useCallback(
|
||||
(order: DashboardtypesListOrderDTO): void => {
|
||||
void logEvent(DashboardListEvents.SortChanged, {
|
||||
column: sortColumn,
|
||||
order,
|
||||
});
|
||||
void setSortOrder(order);
|
||||
void setPage(1);
|
||||
},
|
||||
[setSortOrder, setPage],
|
||||
[setSortOrder, setPage, sortColumn],
|
||||
);
|
||||
|
||||
const visitLoggedRef = useRef(false);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { TableProps } from 'antd/lib';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import DashboardRow from '../DashboardRow/DashboardRow';
|
||||
@@ -50,7 +53,10 @@ function DashboardsListContent({
|
||||
const paginationConfig = total > pageSize && {
|
||||
pageSize,
|
||||
showSizeChanger: false,
|
||||
onChange: onPageChange,
|
||||
onChange: (pageNumber: number): void => {
|
||||
void logEvent(DashboardListEvents.Paginated, { pageNumber });
|
||||
onPageChange(pageNumber);
|
||||
},
|
||||
current: page,
|
||||
total,
|
||||
hideOnSinglePage: true,
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
|
||||
import type { SuggestionSource } from '../../utils/dslSuggestions';
|
||||
import {
|
||||
createdByClause,
|
||||
@@ -65,6 +68,10 @@ function FilterZone({
|
||||
// Created-by (multi-select) only STAGES its clause into the draft; the query runs
|
||||
// when the dropdown closes (FilterChips fires onApply), not on each pick.
|
||||
const handleCreatedByChange = useCallback((emails: string[]): void => {
|
||||
void logEvent(DashboardListEvents.FilterApplied, {
|
||||
filterType: 'createdBy',
|
||||
valueCount: emails.length,
|
||||
});
|
||||
setDraft((d) => spliceClause(d, 'created_by', createdByClause(emails)));
|
||||
}, []);
|
||||
|
||||
@@ -72,6 +79,9 @@ function FilterZone({
|
||||
// immediately (with the fresh value, avoiding the async draft-state lag).
|
||||
const handleUpdatedChange = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
void logEvent(DashboardListEvents.FilterApplied, {
|
||||
filterType: 'updatedWindow',
|
||||
});
|
||||
const next = spliceClause(draft, 'updated_at', updatedClause(window));
|
||||
setDraft(next);
|
||||
const trimmed = next.trim();
|
||||
@@ -94,6 +104,7 @@ function FilterZone({
|
||||
|
||||
// Clear resets the draft and runs immediately (empty query).
|
||||
const handleClear = useCallback((): void => {
|
||||
void logEvent(DashboardListEvents.FiltersCleared, {});
|
||||
setDraft('');
|
||||
if (query !== '') {
|
||||
onQueryChange('');
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { ArrowUpRight, Copy } from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
|
||||
import styles from './LegacyDashboardDialog.module.scss';
|
||||
|
||||
@@ -33,6 +35,18 @@ function LegacyDashboardDialog({
|
||||
const onCopyId = (): void => {
|
||||
copyToClipboard(dashboardId);
|
||||
toast.success('Dashboard ID copied');
|
||||
void logEvent(DashboardListEvents.LegacyDialogAction, {
|
||||
action: 'copyId',
|
||||
dashboardId,
|
||||
});
|
||||
};
|
||||
|
||||
const onContactSupport = (): void => {
|
||||
handleContactSupport(!!isCloudUser);
|
||||
void logEvent(DashboardListEvents.LegacyDialogAction, {
|
||||
action: 'contactSupport',
|
||||
dashboardId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -61,7 +75,7 @@ function LegacyDashboardDialog({
|
||||
color="primary"
|
||||
size="md"
|
||||
suffix={<ArrowUpRight size={14} />}
|
||||
onClick={(): void => handleContactSupport(!!isCloudUser)}
|
||||
onClick={onContactSupport}
|
||||
testId="legacy-dashboard-contact-support"
|
||||
>
|
||||
Contact Support
|
||||
|
||||
@@ -5,10 +5,12 @@ import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ArrowDown, ArrowUp, Check, Columns3 } from '@signozhq/icons';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
|
||||
import {
|
||||
type DashboardDynamicColumns,
|
||||
@@ -61,9 +63,13 @@ function ListHeader({
|
||||
<Switch
|
||||
value={visibleColumns[col.key]}
|
||||
testId={`metadata-toggle-${col.key}`}
|
||||
onChange={(checked): void =>
|
||||
setVisibleColumns({ ...visibleColumns, [col.key]: checked })
|
||||
}
|
||||
onChange={(checked): void => {
|
||||
void logEvent(DashboardListEvents.ColumnsToggled, {
|
||||
column: col.key,
|
||||
visible: checked,
|
||||
});
|
||||
setVisibleColumns({ ...visibleColumns, [col.key]: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
@@ -63,6 +64,12 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
variables: [],
|
||||
},
|
||||
});
|
||||
void logEvent(DashboardListEvents.DashboardCreated, {
|
||||
method: 'blank',
|
||||
hasDescription: Boolean(description.trim()),
|
||||
tagCount: postableTags.length,
|
||||
hasImage: Boolean(image),
|
||||
});
|
||||
onClose();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { normalizeToPostable } from './importUtils';
|
||||
@@ -51,6 +52,7 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
setIsUploadError(false);
|
||||
} catch {
|
||||
setIsUploadError(true);
|
||||
void logEvent(DashboardListEvents.ImportFailed, { reason: 'parse' });
|
||||
}
|
||||
};
|
||||
reader.readAsText(lastFile.originFileObj);
|
||||
@@ -63,6 +65,7 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
|
||||
const payload = normalizeToPostable(parsed);
|
||||
const response = await createDashboardV2(payload);
|
||||
void logEvent(DashboardListEvents.DashboardCreated, { method: 'import' });
|
||||
onClose();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
@@ -70,6 +73,7 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsCreateError(true);
|
||||
void logEvent(DashboardListEvents.ImportFailed, { reason: 'schema' });
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('error_loading_json'),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
|
||||
import BlankDashboardPanel from './BlankDashboardPanel';
|
||||
import ImportJsonPanel from './ImportJsonPanel';
|
||||
@@ -20,6 +22,11 @@ function NewDashboardModal({ open, onClose }: Props): JSX.Element {
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleTabChange = (key: string): void => {
|
||||
setTab(key);
|
||||
void logEvent(DashboardListEvents.CreateModalTabChanged, { tab: key });
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="New dashboard"
|
||||
@@ -33,7 +40,7 @@ function NewDashboardModal({ open, onClose }: Props): JSX.Element {
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(key): void => setTab(key)}
|
||||
onChange={handleTabChange}
|
||||
items={[
|
||||
{
|
||||
key: 'blank',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type MouseEvent, useMemo, useRef } from 'react';
|
||||
import { type MouseEvent, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
autocompletion,
|
||||
closeCompletion,
|
||||
@@ -14,6 +14,8 @@ import CodeMirror, {
|
||||
Prec,
|
||||
type ReactCodeMirrorRef,
|
||||
} from '@uiw/react-codemirror';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
|
||||
import type { SuggestionSource } from '../../utils/dslSuggestions';
|
||||
@@ -83,8 +85,14 @@ function SearchBar({
|
||||
// Refs so the (memoised, stable) extensions always see the latest values.
|
||||
const sourceRef = useRef(source);
|
||||
sourceRef.current = source;
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
const handleSubmit = useCallback((): void => {
|
||||
void logEvent(DashboardListEvents.SearchExecuted, {
|
||||
hasQuery: !!value.trim(),
|
||||
});
|
||||
onSubmit();
|
||||
}, [onSubmit, value]);
|
||||
const onSubmitRef = useRef(handleSubmit);
|
||||
onSubmitRef.current = handleSubmit;
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
@@ -162,7 +170,7 @@ function SearchBar({
|
||||
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={onSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{dirty && (
|
||||
<span
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ChangeEvent, useCallback, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
} from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
|
||||
import type { SavedView } from '../../types';
|
||||
import { type BuiltinView } from '../../utils/views';
|
||||
import ViewNamePopover from './ViewNamePopover';
|
||||
@@ -104,6 +107,7 @@ function ViewsRail({
|
||||
onClick: (e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void logEvent(DashboardListEvents.ViewDeleted, {});
|
||||
onDelete(id);
|
||||
destroy();
|
||||
},
|
||||
@@ -114,6 +118,16 @@ function ViewsRail({
|
||||
[modal, onDelete],
|
||||
);
|
||||
|
||||
const handleSaveAsView = (name: string): void => {
|
||||
void logEvent(DashboardListEvents.ViewSaved, { mode: 'new' });
|
||||
onSave(name);
|
||||
};
|
||||
|
||||
const handleSaveViewChanges = (): void => {
|
||||
void logEvent(DashboardListEvents.ViewSaved, { mode: 'update' });
|
||||
onSaveChanges();
|
||||
};
|
||||
|
||||
const renderItem = (row: ViewRow): JSX.Element => {
|
||||
const Icon = row.icon;
|
||||
const active = row.id === activeViewId;
|
||||
@@ -137,7 +151,10 @@ function ViewsRail({
|
||||
<ViewNamePopover
|
||||
open={renamingId === row.id}
|
||||
onOpenChange={(open): void => setRenamingId(open ? row.id : null)}
|
||||
onSubmit={(name): void => onRename(row.id, name)}
|
||||
onSubmit={(name): void => {
|
||||
void logEvent(DashboardListEvents.ViewRenamed, {});
|
||||
onRename(row.id, name);
|
||||
}}
|
||||
title="Rename view"
|
||||
confirmLabel="Rename"
|
||||
initialName={row.label}
|
||||
@@ -184,7 +201,7 @@ function ViewsRail({
|
||||
<ViewNamePopover
|
||||
open={saveOpen}
|
||||
onOpenChange={setSaveOpen}
|
||||
onSubmit={onSave}
|
||||
onSubmit={handleSaveAsView}
|
||||
title="Save as view"
|
||||
confirmLabel="Save view"
|
||||
testIdPrefix="save-view"
|
||||
@@ -276,7 +293,7 @@ function ViewsRail({
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={onSaveChanges}
|
||||
onClick={handleSaveViewChanges}
|
||||
testId="dashboards-view-save-changes"
|
||||
>
|
||||
Save
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ArrowUpRight, RotateCw } from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
|
||||
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
|
||||
|
||||
@@ -32,6 +34,20 @@ function ErrorState({
|
||||
|
||||
const cleanedDetail = formatQueryErrorMessage(errorMessage);
|
||||
|
||||
const handleRetry = (): void => {
|
||||
void logEvent(DashboardListEvents.ErrorStateAction, {
|
||||
action: 'retry',
|
||||
});
|
||||
onRetry();
|
||||
};
|
||||
|
||||
const handleContactSupportClick = (): void => {
|
||||
void logEvent(DashboardListEvents.ErrorStateAction, {
|
||||
action: 'contactSupport',
|
||||
});
|
||||
handleContactSupport(isCloudUser);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<img src={awwSnapUrl} alt="something went wrong" className={styles.img} />
|
||||
@@ -57,7 +73,7 @@ function ErrorState({
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RotateCw size={16} />}
|
||||
onClick={onRetry}
|
||||
onClick={handleRetry}
|
||||
testId="dashboards-list-retry"
|
||||
>
|
||||
Retry
|
||||
@@ -67,7 +83,7 @@ function ErrorState({
|
||||
variant="link"
|
||||
color="primary"
|
||||
className={styles.learnMore}
|
||||
onClick={(): void => handleContactSupport(isCloudUser)}
|
||||
onClick={handleContactSupportClick}
|
||||
testId="dashboards-list-contact-support"
|
||||
>
|
||||
Contact Support
|
||||
|
||||
36
frontend/src/pages/DashboardsListPageV2/constants/events.ts
Normal file
36
frontend/src/pages/DashboardsListPageV2/constants/events.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Analytics events for the V2 dashboards list page.
|
||||
* Fire via `logEvent(DashboardListEvents.<X>, { ...camelCaseProps })`.
|
||||
*/
|
||||
export enum DashboardListEvents {
|
||||
// Creating / importing
|
||||
CreateModalTabChanged = 'Dashboard List V2: Create modal tab changed',
|
||||
DashboardCreated = 'Dashboard List V2: Dashboard created',
|
||||
ImportFailed = 'Dashboard List V2: Import failed',
|
||||
|
||||
// Finding dashboards (search / filter / sort / columns / pin / pagination)
|
||||
SearchExecuted = 'Dashboard List V2: Search executed',
|
||||
FilterApplied = 'Dashboard List V2: Filter applied',
|
||||
FiltersCleared = 'Dashboard List V2: Filters cleared',
|
||||
SortChanged = 'Dashboard List V2: Sort changed',
|
||||
ColumnsToggled = 'Dashboard List V2: Columns toggled',
|
||||
DashboardPinned = 'Dashboard List V2: Dashboard pinned',
|
||||
Paginated = 'Dashboard List V2: Paginated',
|
||||
|
||||
// Views rail
|
||||
ViewSelected = 'Dashboard List V2: View selected',
|
||||
ViewSaved = 'Dashboard List V2: View saved',
|
||||
ViewRenamed = 'Dashboard List V2: View renamed',
|
||||
ViewDeleted = 'Dashboard List V2: View deleted',
|
||||
ViewReset = 'Dashboard List V2: View reset',
|
||||
RailToggled = 'Dashboard List V2: Rail toggled',
|
||||
|
||||
// Row actions (grouped: view / openNewTab / copyLink / rename / editTags / duplicate / lock / unlock / delete)
|
||||
RowAction = 'Dashboard List V2: Row action',
|
||||
|
||||
// Legacy (V1) dashboard explainer dialog
|
||||
LegacyDialogAction = 'Dashboard List V2: Legacy dialog action',
|
||||
|
||||
// Error / empty states
|
||||
ErrorStateAction = 'Dashboard List V2: Error state action',
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export function GlobalTimeProvider({
|
||||
name,
|
||||
selectedTime: resolveInitialTime(),
|
||||
refreshInterval: initialRefreshInterval ?? 0,
|
||||
parentStore: inheritGlobalTime ? globalStore : undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -38,6 +38,36 @@ const createWrapper = (
|
||||
};
|
||||
};
|
||||
|
||||
const createNestedWrapper = (
|
||||
parentProps: GlobalTimeProviderOptions,
|
||||
childProps: GlobalTimeProviderOptions,
|
||||
nuqsProps?: {
|
||||
searchParams?: string;
|
||||
onUrlUpdate?: (event: { queryString: string }) => void;
|
||||
},
|
||||
) => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
return function NestedWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={nuqsProps?.searchParams}
|
||||
onUrlUpdate={nuqsProps?.onUrlUpdate}
|
||||
>
|
||||
<GlobalTimeProvider {...parentProps}>
|
||||
<GlobalTimeProvider {...childProps}>{children}</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('GlobalTimeProvider', () => {
|
||||
describe('name prop', () => {
|
||||
it('should pass name to store when provided', () => {
|
||||
@@ -82,138 +112,73 @@ describe('GlobalTimeProvider', () => {
|
||||
|
||||
describe('inheritGlobalTime', () => {
|
||||
it('should inherit time from parent store when inheritGlobalTime is true', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime>{children}</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should inherit '6h' from parent provider
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should use initialTime when inheritGlobalTime is false', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: false, initialTime: '15m' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should use its own initialTime, not parent's
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams="?relativeTime=1h">
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true, enableUrlParams: true },
|
||||
{ searchParams: '?relativeTime=1h' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use inherited time when URL params are empty', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams="">
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true, enableUrlParams: true },
|
||||
{ searchParams: '' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// No URL params, should keep inherited value
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should prefer custom time URL params over inheritGlobalTime', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
|
||||
>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true, enableUrlParams: true },
|
||||
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), {
|
||||
wrapper: NestedWrapper,
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// URL custom time params should override inherited time
|
||||
@@ -690,4 +655,151 @@ describe('GlobalTimeProvider', () => {
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetToParentTime', () => {
|
||||
it('should return false when no parent store exists', () => {
|
||||
const wrapper = createWrapper({ initialTime: '1h' });
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.resetToParentTime()).toBe(false);
|
||||
expect(result.current.parentStore).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have parentStore when inheritGlobalTime is true', () => {
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.parentStore).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reset to parent time when called', () => {
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.selectedTime).toBe('6h');
|
||||
const initialMinMax = result.current.lastComputedMinMax;
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
const changedMinMax = result.current.lastComputedMinMax;
|
||||
expect(changedMinMax.maxTime - changedMinMax.minTime).toBeLessThan(
|
||||
initialMinMax.maxTime - initialMinMax.minTime,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
const success = result.current.resetToParentTime();
|
||||
expect(success).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('6h');
|
||||
const resetMinMax = result.current.lastComputedMinMax;
|
||||
expect(resetMinMax.maxTime - resetMinMax.minTime).toBeGreaterThan(
|
||||
changedMinMax.maxTime - changedMinMax.minTime,
|
||||
);
|
||||
});
|
||||
|
||||
it('should set shouldClearUrlParams flag when reset', () => {
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resetToParentTime();
|
||||
});
|
||||
|
||||
expect(result.current.shouldClearUrlParams).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.clearUrlParamsFlag();
|
||||
});
|
||||
|
||||
expect(result.current.shouldClearUrlParams).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear URL params when resetToParentTime is called with enableUrlParams', async () => {
|
||||
let currentQueryString = 'relativeTime=15m';
|
||||
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true, enableUrlParams: true },
|
||||
{
|
||||
searchParams: currentQueryString,
|
||||
onUrlUpdate: (event): void => {
|
||||
currentQueryString = event.queryString;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resetToParentTime();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('relativeTime');
|
||||
expect(currentQueryString).not.toContain('startTime');
|
||||
expect(currentQueryString).not.toContain('endTime');
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('6h');
|
||||
});
|
||||
|
||||
it('should clear custom time URL params when resetToParentTime is called', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
let currentQueryString = `startTime=${startTime}&endTime=${endTime}`;
|
||||
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true, enableUrlParams: true },
|
||||
{
|
||||
searchParams: currentQueryString,
|
||||
onUrlUpdate: (event): void => {
|
||||
currentQueryString = event.queryString;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedTime).toContain('||_||');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resetToParentTime();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('startTime');
|
||||
expect(currentQueryString).not.toContain('endTime');
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('6h');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constan
|
||||
import {
|
||||
GlobalTimeSelectedTime,
|
||||
GlobalTimeState,
|
||||
GlobalTimeStoreApiRef,
|
||||
GlobalTimeStore,
|
||||
ParsedTimeRange,
|
||||
} from './types';
|
||||
@@ -18,6 +19,10 @@ import {
|
||||
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
|
||||
export type IGlobalTimeStore = GlobalTimeStore;
|
||||
|
||||
export interface CreateGlobalTimeStoreOptions extends Partial<GlobalTimeState> {
|
||||
parentStore?: GlobalTimeStoreApiRef;
|
||||
}
|
||||
|
||||
function computeIsRefreshEnabled(
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
refreshInterval: number,
|
||||
@@ -29,11 +34,12 @@ function computeIsRefreshEnabled(
|
||||
}
|
||||
|
||||
export function createGlobalTimeStore(
|
||||
initialState?: Partial<GlobalTimeState>,
|
||||
options?: CreateGlobalTimeStoreOptions,
|
||||
): GlobalTimeStoreApi {
|
||||
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
|
||||
const refreshInterval = initialState?.refreshInterval ?? 0;
|
||||
const name = initialState?.name;
|
||||
const selectedTime = options?.selectedTime ?? DEFAULT_TIME_RANGE;
|
||||
const refreshInterval = options?.refreshInterval ?? 0;
|
||||
const name = options?.name;
|
||||
const parentStore = options?.parentStore;
|
||||
|
||||
return createStore<GlobalTimeStore>((set, get) => ({
|
||||
name,
|
||||
@@ -42,6 +48,8 @@ export function createGlobalTimeStore(
|
||||
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
|
||||
lastRefreshTimestamp: 0,
|
||||
lastComputedMinMax: safeParseSelectedTime(selectedTime),
|
||||
parentStore,
|
||||
shouldClearUrlParams: false,
|
||||
|
||||
setSelectedTime: (
|
||||
time: GlobalTimeSelectedTime,
|
||||
@@ -130,6 +138,29 @@ export function createGlobalTimeStore(
|
||||
}
|
||||
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
|
||||
},
|
||||
|
||||
resetToParentTime: (): boolean => {
|
||||
const state = get();
|
||||
if (!state.parentStore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parentSelectedTime = state.parentStore.getState().selectedTime;
|
||||
const computedMinMax = parseSelectedTime(parentSelectedTime);
|
||||
|
||||
set({
|
||||
selectedTime: parentSelectedTime,
|
||||
lastComputedMinMax: computedMinMax,
|
||||
lastRefreshTimestamp: Date.now(),
|
||||
shouldClearUrlParams: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
clearUrlParamsFlag: (): void => {
|
||||
set({ shouldClearUrlParams: false });
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user