Compare commits

...

24 Commits

Author SHA1 Message Date
nityanandagohain
9daa31892a Merge remote-tracking branch 'origin/main' into issue_5267 2026-07-21 22:26:04 +05:30
nityanandagohain
ad2f7b8601 fix: cleanup integration test 2026-07-21 22:26:00 +05:30
Ashwin Bhatkal
c7f4aa01c4 feat(dashboard-v2): product analytics instrumentation for list + detail pages (#12199)
* feat(dashboard-v2): add analytics event catalog for list + detail

Introduce DashboardListEvents and DashboardDetailEvents enums as the single
source of truth for V2 dashboard product-analytics event names. Grouped
action-discriminator events (RowAction, SectionAction, PanelAction,
DrilldownAction, JsonEditorAction, PublicDashboardAction) mirror the existing
'Dashboard Detail: Panel action' pattern. PanelAction reuses the existing V1
string so panel actions stay comparable across versions.

* feat(dashboard-v2): instrument dashboards list page analytics

Fire logEvent for create/import funnel (tab switch, created success, import
failed), find/navigate (search, filter, sort, columns, pin, pagination),
Views rail (select/save/rename/delete/reset, rail toggle), row actions
(view/open-new-tab/copy-link/rename/edit-tags/duplicate/lock/delete),
legacy-dashboard dialog, and error-state actions.

* feat(dashboard-v2): instrument dashboard detail page analytics

Fire logEvent across the detail page: lifecycle (open, rename, clone, delete,
lock, full-screen, stale-reload, public-url), sections (add/rename/clone/delete,
collapse, reorder, first-section migration), panels (add, edit/clone/delete/move,
export csv/png/svg, view, editor save/discard, type change, search, no-data),
layout drag/resize (rate-limited), variables setup (save/delete/reorder/test/
apply-to-all) and runtime selection (value selected rate-limited, multiselect
clear, options-fetch failed), drill-down (open + view-logs/traces/breakout/
filter/set-variable/create-variable), panel zoom, JSON editor actions, settings
overview save/discard, cursor sync, and public-dashboard actions.

* fix: lint

* refactor(dashboard-v2): extract inline analytics handlers into named functions

Address PR review: pull the inline JSX arrow handlers that wrap logEvent out
into named handlers across both V2 dashboard pages (public-url open, cursor/
tooltip sync, settings/JSON/full-screen actions, panel search, drilldown
view-logs/traces, create-modal tab, error retry/contact-support, row open-in-
new-tab/copy-link, legacy contact-support, save-view actions). Behavior
unchanged. Handlers defined inside .map()/per-row renderers and pre-existing
complex value-mapping handlers (ValueSelector) are intentionally left inline.
2026-07-21 14:13:26 +00:00
Vinicius Lourenço
e93792d427 fix(overlayscrollbars): leaking memory due to img.load event listener (#12179)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-21 12:07:55 +00:00
Vinicius Lourenço
6d0f618a9e feat(infra-monitoring): persist custom time on drawer across categories (#12038)
* feat(globalTime): add method to reset time of child to parent

* refactor(infra-monitoring): extract content from K8sBaseDetails

* feat(entity-details): add support to persist custom time across categories of k8s

* test(entity-details): update existing tests

* test(entity-details): add regression test for time conversion

* fix(k8s-list): ensure default value for list is 30m

* fix(entity-counts-section): address todo around inherit selected time

* fix(pr): missing fix for tab validation, lost of moving/merge
2026-07-21 11:58:26 +00:00
Ashwin Bhatkal
564d479aae test(e2e): fix teardown crashes (network endpoint race + eager keeper lookup) (#12204)
* test(e2e): retry network teardown to survive async endpoint detach

`--teardown` fails on CI with `docker.errors.APIError: 403 ... error while
removing network ... has active endpoints`: containers are torn down before
the network, but Docker detaches endpoints asynchronously, so the network
can still report active endpoints for a moment. Retry the removal,
force-disconnecting any stragglers each round.

* test(e2e): resolve keeper coordinator lazily so --teardown doesn't crash

`create_clickhouse` / `create_clickhouse_cluster` computed
`next(iter(keeper.container_configs.values()))` eagerly. Under `--teardown`
the keeper fixture is empty, so it raised StopIteration during teardown
setup, before any finalizer ran. Move the lookup inside create().
2026-07-21 11:44:30 +00:00
Vikrant Gupta
253ca7dd7e fix(session): validate ref and callback state against global allowed_origins (#12172)
* fix(session): use global external_url instead of ref param for SSO state

The sessions/context endpoint no longer reads the client-controlled ref
query param to build the SSO state and callback URLs. Each callback
authn provider now derives the site URL from the server-configured
global external_url, and siteURL is removed from the CallbackAuthN and
session Module interfaces.

* fix(session): validate ref and callback state against global allowed_origins

Instead of deriving SSO urls from the global external_url, keep the ref
roundtrip and validate its origin against the new optional
global.allowed_origins config. The callback state url is re-validated
before tokens are attached to it, closing the forged RelayState/state
exfiltration path. When allowed_origins is not configured, redirect
targets are not validated, preserving existing installs.

* fix(session): scope ref origin validation to sso auth domains

Move the allowed_origins check from the sessions/context handler to
getOrgSessionContext, right before the SSO login URL is built. A
disallowed ref no longer fails the whole request; only orgs with an
SSO-enabled auth domain get a per-org warning with password fallback.
2026-07-21 10:28:49 +00:00
Gaurav Tewari
453a524ff4 Merge branch 'main' into issue_5267 2026-07-20 18:18:46 +05:30
Gaurav Tewari
08f92df0a6 Merge branch 'main' into issue_5267 2026-07-20 16:20:40 +05:30
Nityananda Gohain
6e5ff46761 Merge branch 'main' into issue_5267 2026-07-20 10:09:13 +05:30
nityanandagohain
72448fa163 fix: update group_id to groupId 2026-07-14 22:20:33 +05:30
nityanandagohain
a45f6341da Merge remote-tracking branch 'origin/main' into issue_5267 2026-07-14 21:50:19 +05:30
nityanandagohain
f534ea2467 fix: minor cleanup 2026-07-14 21:47:02 +05:30
nityanandagohain
d4843ecd8b Merge remote-tracking branch 'origin/main' into issue_5267 2026-07-14 21:10:50 +05:30
nityanandagohain
477b81892d fix: update go.mod 2026-07-14 21:09:28 +05:30
nityanandagohain
9604438723 Merge remote-tracking branch 'origin/main' into issue_5267 2026-06-25 17:23:58 +05:30
nityanandagohain
e0a66b7a10 fix: more cleanup 2026-06-25 17:20:48 +05:30
nityanandagohain
b01be1f31c fix: update openapi 2026-06-25 11:19:08 +05:30
nityanandagohain
c8a007cd8d fix: cleanup go.mod 2026-06-25 10:55:54 +05:30
nityanandagohain
205b23b830 fix: lint 2026-06-25 10:52:27 +05:30
nityanandagohain
68b166f75e fix: more cleanup and integration test 2026-06-25 10:50:52 +05:30
nityanandagohain
20d8ca6d7d Merge remote-tracking branch 'origin/main' into issue_5267 2026-06-25 10:06:52 +05:30
nityanandagohain
1e1be670f1 feat: integrate with collector 2026-06-23 12:07:16 +05:30
nityanandagohain
d1682f2ab6 feat: span mapper test endpoint 2026-06-19 18:31:31 +05:30
122 changed files with 3670 additions and 1039 deletions

View File

@@ -58,6 +58,7 @@ jobs:
- role
- rootuser
- serviceaccount
- spanmapper
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -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.

View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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:

View 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 } }),
),
),
);
}

View File

@@ -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 = {

View File

@@ -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));
};

View File

@@ -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>
);

View File

@@ -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,
)}
</>
);
}

View File

@@ -245,6 +245,7 @@ function K8sHeader<TData>({
showAutoRefresh={showAutoRefresh}
showRefreshText={false}
hideShareModal
defaultRelativeTime="30m"
/>
</div>

View File

@@ -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()}`;
};

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -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

View File

@@ -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"'

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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"'

View File

@@ -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);
});
});

View File

@@ -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}>

View File

@@ -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,
}),

View File

@@ -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,
),
);
});
});

View File

@@ -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

View File

@@ -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,
},
});
});

View File

@@ -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);
});
});

View File

@@ -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(),
}),
}));

View File

@@ -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}
/>
),
};

View File

@@ -454,3 +454,9 @@
gap: 16px;
}
}
.entity-date-time-selector {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -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 */

View File

@@ -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: {

View 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 },
});
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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 => {

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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 },

View File

@@ -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}>

View File

@@ -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;

View File

@@ -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,
});
}
};

View File

@@ -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);
};

View File

@@ -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 {

View File

@@ -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');
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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],

View File

@@ -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, {

View File

@@ -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 (

View File

@@ -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();
}}

View File

@@ -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') {

View File

@@ -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}

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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]);
}

View File

@@ -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: {

View File

@@ -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 => {

View File

@@ -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,

View File

@@ -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,
],
);

View File

@@ -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();
},

View File

@@ -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);
}

View File

@@ -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],

View File

@@ -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],

View File

@@ -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;

View File

@@ -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.
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)]);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 };
}

View File

@@ -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],
);

View File

@@ -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 });
}}
/>
);
}

View File

@@ -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}

View File

@@ -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 };
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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..." />;
}

View File

@@ -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',
}

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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();
},

View File

@@ -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();
},

View File

@@ -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';

View File

@@ -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);

View File

@@ -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,

View File

@@ -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('');

View File

@@ -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

View File

@@ -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>
))}

View File

@@ -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 }),

View File

@@ -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'),
);

View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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',
}

View File

@@ -55,6 +55,7 @@ export function GlobalTimeProvider({
name,
selectedTime: resolveInitialTime(),
refreshInterval: initialRefreshInterval ?? 0,
parentStore: inheritGlobalTime ? globalStore : undefined,
}),
);

View File

@@ -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');
});
});
});

View File

@@ -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