mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-21 19:30:29 +01:00
Compare commits
6 Commits
feat/dropd
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
368c6cd724 | ||
|
|
7917540662 | ||
|
|
addb234c8c | ||
|
|
be6a663e4b | ||
|
|
ed17003329 | ||
|
|
50b452080f |
@@ -4360,146 +4360,6 @@ components:
|
||||
TimeDuration:
|
||||
format: int64
|
||||
type: integer
|
||||
TracedetailtypesEvent:
|
||||
properties:
|
||||
attributeMap:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
isError:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
timeUnixNano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
TracedetailtypesWaterfallRequest:
|
||||
properties:
|
||||
limit:
|
||||
minimum: 0
|
||||
type: integer
|
||||
selectedSpanId:
|
||||
type: string
|
||||
uncollapsedSpans:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
TracedetailtypesWaterfallResponse:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
hasMissingSpans:
|
||||
type: boolean
|
||||
hasMore:
|
||||
type: boolean
|
||||
rootServiceEntryPoint:
|
||||
type: string
|
||||
rootServiceName:
|
||||
type: string
|
||||
serviceNameToTotalDurationMap:
|
||||
additionalProperties:
|
||||
minimum: 0
|
||||
type: integer
|
||||
nullable: true
|
||||
type: object
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/TracedetailtypesWaterfallSpan'
|
||||
nullable: true
|
||||
type: array
|
||||
startTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalErrorSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
uncollapsedSpans:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
TracedetailtypesWaterfallSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
db_name:
|
||||
type: string
|
||||
db_operation:
|
||||
type: string
|
||||
duration_nano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
$ref: '#/components/schemas/TracedetailtypesEvent'
|
||||
nullable: true
|
||||
type: array
|
||||
external_http_method:
|
||||
type: string
|
||||
external_http_url:
|
||||
type: string
|
||||
flags:
|
||||
minimum: 0
|
||||
type: integer
|
||||
has_children:
|
||||
type: boolean
|
||||
has_error:
|
||||
type: boolean
|
||||
http_host:
|
||||
type: string
|
||||
http_method:
|
||||
type: string
|
||||
http_url:
|
||||
type: string
|
||||
is_remote:
|
||||
type: string
|
||||
kind:
|
||||
format: int32
|
||||
type: integer
|
||||
kind_string:
|
||||
type: string
|
||||
level:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
response_status_code:
|
||||
type: string
|
||||
span_id:
|
||||
type: string
|
||||
status_code:
|
||||
type: integer
|
||||
status_code_string:
|
||||
type: string
|
||||
status_message:
|
||||
type: string
|
||||
sub_tree_node_count:
|
||||
minimum: 0
|
||||
type: integer
|
||||
timestamp:
|
||||
minimum: 0
|
||||
type: integer
|
||||
trace_id:
|
||||
type: string
|
||||
trace_state:
|
||||
type: string
|
||||
type: object
|
||||
TypesAlertStatus:
|
||||
properties:
|
||||
inhibitedBy:
|
||||
@@ -12712,76 +12572,6 @@ paths:
|
||||
summary: Put profile in Zeus for a deployment.
|
||||
tags:
|
||||
- zeus
|
||||
/api/v3/traces/{traceID}/waterfall:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns the waterfall view of spans for a given trace ID with tree
|
||||
structure, metadata, and windowed pagination
|
||||
operationId: GetWaterfall
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TracedetailtypesWaterfallRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TracedetailtypesWaterfallResponse'
|
||||
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: Get waterfall view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v5/query_range:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -62,11 +62,10 @@
|
||||
"@signozhq/popover": "0.1.2",
|
||||
"@signozhq/radio-group": "0.0.4",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/tooltip": "0.0.2",
|
||||
"@signozhq/ui": "0.0.6",
|
||||
"@signozhq/tabs": "0.0.11",
|
||||
"@signozhq/table": "0.3.8",
|
||||
"@signozhq/toggle-group": "0.0.3",
|
||||
"@signozhq/ui": "0.0.5",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -138,12 +137,10 @@
|
||||
"react-helmet-async": "1.3.0",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-i18next": "^11.16.1",
|
||||
"react-json-tree": "^0.20.0",
|
||||
"react-lottie": "1.2.10",
|
||||
"react-markdown": "8.0.7",
|
||||
"react-query": "3.39.3",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-rnd": "^10.5.3",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-dom-v5-compat": "6.27.0",
|
||||
"react-syntax-highlighter": "15.5.0",
|
||||
|
||||
@@ -65,13 +65,6 @@ export const TraceDetail = Loadable(
|
||||
),
|
||||
);
|
||||
|
||||
export const TraceDetailV3 = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailV3Page/index'
|
||||
),
|
||||
);
|
||||
|
||||
export const UsageExplorerPage = Loadable(
|
||||
() => import(/* webpackChunkName: "UsageExplorerPage" */ 'modules/Usage'),
|
||||
);
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
StatusPage,
|
||||
SupportPage,
|
||||
TraceDetail,
|
||||
TraceDetailV3,
|
||||
TraceFilter,
|
||||
TracesExplorer,
|
||||
TracesFunnelDetails,
|
||||
@@ -142,16 +141,9 @@ const routes: AppRoutes[] = [
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL,
|
||||
exact: true,
|
||||
component: TraceDetailV3,
|
||||
isPrivate: true,
|
||||
key: 'TRACE_DETAIL',
|
||||
},
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL_OLD,
|
||||
exact: true,
|
||||
component: TraceDetail,
|
||||
isPrivate: true,
|
||||
key: 'TRACE_DETAIL_OLD',
|
||||
key: 'TRACE_DETAIL',
|
||||
},
|
||||
{
|
||||
path: ROUTES.SETTINGS,
|
||||
|
||||
@@ -5332,248 +5332,6 @@ export interface TelemetrytypesTelemetryFieldValuesDTO {
|
||||
|
||||
export type TimeDurationDTO = number;
|
||||
|
||||
export type TracedetailtypesEventDTOAttributeMap = { [key: string]: unknown };
|
||||
|
||||
export interface TracedetailtypesEventDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
attributeMap?: TracedetailtypesEventDTOAttributeMap;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isError?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
timeUnixNano?: number;
|
||||
}
|
||||
|
||||
export interface TracedetailtypesWaterfallRequestDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
selectedSpanId?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TracedetailtypesWaterfallResponseDTOServiceNameToTotalDurationMap = {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
|
||||
export interface TracedetailtypesWaterfallResponseDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
endTimestampMillis?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMissingSpans?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceEntryPoint?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName?: string;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
serviceNameToTotalDurationMap?: TracedetailtypesWaterfallResponseDTOServiceNameToTotalDurationMap;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
spans?: TracedetailtypesWaterfallSpanDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
startTimestampMillis?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalErrorSpansCount?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalSpansCount?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TracedetailtypesWaterfallSpanDTOAttributes = {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TracedetailtypesWaterfallSpanDTOResource = {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
|
||||
export interface TracedetailtypesWaterfallSpanDTO {
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
attributes?: TracedetailtypesWaterfallSpanDTOAttributes;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_operation?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
duration_nano?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
events?: TracedetailtypesEventDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_url?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
flags?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_children?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_error?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_host?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_url?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
is_remote?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int32
|
||||
*/
|
||||
kind?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind_string?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
level?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id?: string;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
resource?: TracedetailtypesWaterfallSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
response_status_code?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
span_id?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
status_code?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_code_string?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_message?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
sub_tree_node_count?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
timestamp?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_state?: string;
|
||||
}
|
||||
|
||||
export interface TypesAlertStatusDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -7500,17 +7258,6 @@ export type GetHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetWaterfallPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetWaterfall200 = {
|
||||
data: TracedetailtypesWaterfallResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type QueryRangeV5200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
MutationFunction,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
} from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type {
|
||||
GetWaterfall200,
|
||||
GetWaterfallPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
TracedetailtypesWaterfallRequestDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
export const getWaterfall = (
|
||||
{ traceID }: GetWaterfallPathParameters,
|
||||
tracedetailtypesWaterfallRequestDTO: BodyType<TracedetailtypesWaterfallRequestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetWaterfall200>({
|
||||
url: `/api/v3/traces/${traceID}/waterfall`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: tracedetailtypesWaterfallRequestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetWaterfallMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['getWaterfall'];
|
||||
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 getWaterfall>>,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return getWaterfall(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type GetWaterfallMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getWaterfall>>
|
||||
>;
|
||||
export type GetWaterfallMutationBody = BodyType<TracedetailtypesWaterfallRequestDTO>;
|
||||
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
export const useGetWaterfall = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getGetWaterfallMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ApiV3Instance as axios } from 'api';
|
||||
import { omit } from 'lodash-es';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV3PayloadProps,
|
||||
GetTraceV3SuccessResponse,
|
||||
SpanV3,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
const getTraceV3 = async (
|
||||
props: GetTraceV3PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
|
||||
let uncollapsedSpans = [...props.uncollapsedSpans];
|
||||
if (!props.isSelectedSpanIDUnCollapsed) {
|
||||
uncollapsedSpans = uncollapsedSpans.filter(
|
||||
(node) => node !== props.selectedSpanId,
|
||||
);
|
||||
} else if (
|
||||
props.selectedSpanId &&
|
||||
!uncollapsedSpans.includes(props.selectedSpanId)
|
||||
) {
|
||||
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
|
||||
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
|
||||
uncollapsedSpans.push(props.selectedSpanId);
|
||||
}
|
||||
const postData: GetTraceV3PayloadProps = {
|
||||
...props,
|
||||
uncollapsedSpans,
|
||||
limit: 10000,
|
||||
};
|
||||
const response = await axios.post<GetTraceV3SuccessResponse>(
|
||||
`/traces/${props.traceId}/waterfall`,
|
||||
omit(postData, 'traceId'),
|
||||
);
|
||||
|
||||
// V3 API wraps response in { status, data }
|
||||
const rawPayload = (response.data as any).data || response.data;
|
||||
|
||||
// Derive 'service.name' from resource for convenience — only derived field
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
}));
|
||||
|
||||
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
|
||||
// Convert by using the first span's timestamp as the base if there's a mismatch.
|
||||
let { startTimestampMillis, endTimestampMillis } = rawPayload;
|
||||
if (
|
||||
spans.length > 0 &&
|
||||
spans[0].timestamp > 0 &&
|
||||
startTimestampMillis < spans[0].timestamp / 10
|
||||
) {
|
||||
const durationMillis = endTimestampMillis - startTimestampMillis;
|
||||
startTimestampMillis = spans[0].timestamp;
|
||||
endTimestampMillis = startTimestampMillis + durationMillis;
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: {
|
||||
...rawPayload,
|
||||
spans,
|
||||
startTimestampMillis,
|
||||
endTimestampMillis,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default getTraceV3;
|
||||
3
frontend/src/auto-import-registry.d.ts
vendored
3
frontend/src/auto-import-registry.d.ts
vendored
@@ -24,8 +24,7 @@ import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
import '@signozhq/radio-group';
|
||||
import '@signozhq/resizable';
|
||||
import '@signozhq/tooltip';
|
||||
import '@signozhq/ui';
|
||||
import '@signozhq/tabs';
|
||||
import '@signozhq/table';
|
||||
import '@signozhq/toggle-group';
|
||||
import '@signozhq/ui';
|
||||
|
||||
@@ -79,7 +79,7 @@ export function useNavigateToExplorer(): (
|
||||
);
|
||||
|
||||
const { getUpdatedQuery } = useUpdatedQuery();
|
||||
const { selectedDashboard } = useDashboardStore();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
return useCallback(
|
||||
@@ -111,7 +111,7 @@ export function useNavigateToExplorer(): (
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
},
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
})
|
||||
.then((query) => {
|
||||
preparedQuery = query;
|
||||
@@ -140,7 +140,7 @@ export function useNavigateToExplorer(): (
|
||||
minTime,
|
||||
maxTime,
|
||||
getUpdatedQuery,
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
notifications,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
.details-header {
|
||||
// ghost + secondary missing hover bg token in @signozhq/button
|
||||
--button-ghost-hover-background: var(--l3-background);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
height: 36px;
|
||||
background: var(--l2-background);
|
||||
|
||||
&__icon-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import './DetailsHeader.styles.scss';
|
||||
|
||||
export interface HeaderAction {
|
||||
key: string;
|
||||
component: ReactNode; // check later if we can use direct btn itself or not.
|
||||
}
|
||||
|
||||
export interface DetailsHeaderProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
actions?: HeaderAction[];
|
||||
closePosition?: 'left' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailsHeader({
|
||||
title,
|
||||
onClose,
|
||||
actions,
|
||||
closePosition = 'right',
|
||||
className,
|
||||
}: DetailsHeaderProps): JSX.Element {
|
||||
const closeButton = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="details-header__icon-btn"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`details-header ${className || ''}`}>
|
||||
{closePosition === 'left' && closeButton}
|
||||
|
||||
<span className="details-header__title">{title}</span>
|
||||
|
||||
{actions && (
|
||||
<div className="details-header__actions">
|
||||
{actions.map((action) => (
|
||||
<div key={action.key}>{action.component}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{closePosition === 'right' && closeButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailsHeader;
|
||||
@@ -1,7 +0,0 @@
|
||||
.details-panel-drawer {
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
|
||||
import './DetailsPanelDrawer.styles.scss';
|
||||
|
||||
interface DetailsPanelDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailsPanelDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
}: DetailsPanelDrawerProps): JSX.Element {
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
type="panel"
|
||||
showOverlay={false}
|
||||
allowOutsideClick
|
||||
className={`details-panel-drawer ${className || ''}`}
|
||||
content={<div className="details-panel-drawer__body">{children}</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailsPanelDrawer;
|
||||
@@ -1,8 +0,0 @@
|
||||
export type {
|
||||
DetailsHeaderProps,
|
||||
HeaderAction,
|
||||
} from './DetailsHeader/DetailsHeader';
|
||||
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
|
||||
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
|
||||
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
|
||||
export { default as useDetailsPanel } from './useDetailsPanel';
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface DetailsPanelState {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface UseDetailsPanelOptions {
|
||||
entityId: string | undefined;
|
||||
onClose?: () => void;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
|
||||
|
||||
function useDetailsPanel({
|
||||
entityId,
|
||||
onClose,
|
||||
}: UseDetailsPanelOptions): DetailsPanelState {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const prevEntityIdRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const currentId = entityId || '';
|
||||
if (currentId && currentId !== prevEntityIdRef.current) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
prevEntityIdRef.current = currentId;
|
||||
}, [entityId]);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
return { isOpen, open, close };
|
||||
}
|
||||
|
||||
export default useDetailsPanel;
|
||||
@@ -15,6 +15,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import JSONView from 'container/LogDetailedView/JsonView';
|
||||
import Overview from 'container/LogDetailedView/Overview';
|
||||
import {
|
||||
aggregateAttributesResourcesToString,
|
||||
@@ -44,7 +45,6 @@ import {
|
||||
TextSelect,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
@@ -527,9 +527,7 @@ function LogDetailInner({
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.JSON && (
|
||||
<JsonView data={LogJsonData} height="68vh" />
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
|
||||
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<ContextView
|
||||
|
||||
@@ -87,8 +87,8 @@ jest.mock('hooks/useDarkMode', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (): { selectedDashboard: undefined } => ({
|
||||
selectedDashboard: undefined,
|
||||
useDashboardStore: (): { dashboardData: undefined } => ({
|
||||
dashboardData: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
.timeline-v3-container {
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-v3-cursor-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { resolveTimeFromInterval } from 'components/TimelineV2/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import {
|
||||
getIntervals,
|
||||
getIntervalUnit,
|
||||
getMinimumIntervalsBasedOnWidth,
|
||||
Interval,
|
||||
} from './utils';
|
||||
|
||||
import './TimelineV3.styles.scss';
|
||||
|
||||
interface ITimelineV3Props {
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
timelineHeight: number;
|
||||
offsetTimestamp: number;
|
||||
/** Cursor X as a fraction of the timeline width (0–1). null = no cursor. */
|
||||
cursorXPercent?: number | null;
|
||||
}
|
||||
|
||||
function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
const {
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
timelineHeight,
|
||||
offsetTimestamp,
|
||||
cursorXPercent,
|
||||
} = props;
|
||||
const [intervals, setIntervals] = useState<Interval[]>([]);
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const spread = endTimestamp - startTimestamp;
|
||||
|
||||
useEffect(() => {
|
||||
if (spread < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalisedSpread = (spread / minIntervals) * 1.0;
|
||||
const newIntervals = getIntervals(
|
||||
intervalisedSpread,
|
||||
spread,
|
||||
offsetTimestamp,
|
||||
);
|
||||
|
||||
setIntervals(newIntervals);
|
||||
}, [startTimestamp, endTimestamp, width, offsetTimestamp, spread]);
|
||||
|
||||
// Compute cursor time label using the same unit as timeline ticks
|
||||
const cursorLabel = useMemo(() => {
|
||||
if (cursorXPercent == null || spread <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
|
||||
const unit = getIntervalUnit(spread, offsetTimestamp);
|
||||
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
|
||||
return `${formatted}${unit.name}`;
|
||||
}, [cursorXPercent, spread, offsetTimestamp]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
'endTimestamp cannot be less than startTimestamp',
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
);
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
|
||||
const svgHeight = timelineHeight * 2.5;
|
||||
const cursorX = cursorXPercent != null ? cursorXPercent * width : null;
|
||||
|
||||
return (
|
||||
<div ref={ref as never} className="timeline-v3-container">
|
||||
<svg
|
||||
width={width}
|
||||
height={svgHeight}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
overflow="visible"
|
||||
>
|
||||
{intervals &&
|
||||
intervals.length > 0 &&
|
||||
intervals.map((interval, index) => (
|
||||
<g
|
||||
transform={`translate(${(interval.percentage * width) / 100},0)`}
|
||||
key={`${interval.percentage + interval.label + index}`}
|
||||
textAnchor="middle"
|
||||
fontSize="0.6rem"
|
||||
>
|
||||
<text
|
||||
x={index === intervals.length - 1 ? -10 : 0}
|
||||
y={timelineHeight * 2}
|
||||
fill={strokeColor}
|
||||
>
|
||||
{interval.label}
|
||||
</text>
|
||||
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Cursor time badge — DOM element for easy CSS styling */}
|
||||
{cursorX !== null && cursorLabel && (
|
||||
<div className="timeline-v3-cursor-badge" style={{ left: cursorX }}>
|
||||
{cursorLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineV3;
|
||||
@@ -1,109 +0,0 @@
|
||||
import {
|
||||
IIntervalUnit,
|
||||
Interval,
|
||||
INTERVAL_UNITS,
|
||||
resolveTimeFromInterval,
|
||||
} from 'components/TimelineV2/utils';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
export type { Interval };
|
||||
|
||||
/**
|
||||
* Select the interval unit matching the timeline's logic.
|
||||
* Exported so crosshair labels use the same unit as timeline ticks.
|
||||
*/
|
||||
export function getIntervalUnit(
|
||||
spread: number,
|
||||
offsetTimestamp: number,
|
||||
): IIntervalUnit {
|
||||
const minIntervals = 6;
|
||||
const intervalSpread = spread / minIntervals;
|
||||
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
|
||||
let unit: IIntervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
if (valueForUnitSelection * INTERVAL_UNITS[idx].multiplier >= 1) {
|
||||
unit = INTERVAL_UNITS[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return unit;
|
||||
}
|
||||
|
||||
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
|
||||
export function getMinimumIntervalsBasedOnWidth(width: number): number {
|
||||
if (width < 640) {
|
||||
return 3;
|
||||
}
|
||||
if (width < 768) {
|
||||
return 4;
|
||||
}
|
||||
if (width < 1024) {
|
||||
return 5;
|
||||
}
|
||||
return 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes timeline intervals with offset-aware labels.
|
||||
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
|
||||
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
|
||||
*/
|
||||
export function getIntervals(
|
||||
intervalSpread: number,
|
||||
baseSpread: number,
|
||||
offsetTimestamp: number,
|
||||
): Interval[] {
|
||||
const integerPartString = intervalSpread.toString().split('.')[0];
|
||||
const integerPartLength = integerPartString.length;
|
||||
|
||||
const intervalSpreadNormalized =
|
||||
intervalSpread < 1.0
|
||||
? intervalSpread
|
||||
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
|
||||
10 ** (integerPartLength - 1);
|
||||
|
||||
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
|
||||
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
|
||||
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
|
||||
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
|
||||
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
const standardInterval = INTERVAL_UNITS[idx];
|
||||
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
|
||||
intervalUnit = INTERVAL_UNITS[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const intervals: Interval[] = [
|
||||
{
|
||||
label: `${toFixed(
|
||||
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
|
||||
2,
|
||||
)}${intervalUnit.name}`,
|
||||
percentage: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show even-interval ticks — skip the trailing partial tick at the edge.
|
||||
// The last even tick sits before the full width, so it doesn't conflict with
|
||||
// span duration labels that may have sub-millisecond precision.
|
||||
let elapsedIntervals = 0;
|
||||
|
||||
while (
|
||||
elapsedIntervals + intervalSpreadNormalized <= baseSpread &&
|
||||
intervals.length < 20
|
||||
) {
|
||||
elapsedIntervals += intervalSpreadNormalized;
|
||||
const labelTime = offsetTimestamp + elapsedIntervals;
|
||||
|
||||
intervals.push({
|
||||
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
percentage: (elapsedIntervals / baseSpread) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
@@ -37,6 +37,5 @@ export enum LOCALSTORAGE {
|
||||
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export const REACT_QUERY_KEY = {
|
||||
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
|
||||
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
|
||||
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
|
||||
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
|
||||
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
|
||||
GET_POD_LIST: 'GET_POD_LIST',
|
||||
GET_NODE_LIST: 'GET_NODE_LIST',
|
||||
|
||||
@@ -8,7 +8,6 @@ const ROUTES = {
|
||||
SERVICE_MAP: '/service-map',
|
||||
TRACE: '/trace',
|
||||
TRACE_DETAIL: '/trace/:id',
|
||||
TRACE_DETAIL_OLD: '/trace-old/:id',
|
||||
TRACES_EXPLORER: '/traces-explorer',
|
||||
ONBOARDING: '/onboarding',
|
||||
GET_STARTED: '/get-started',
|
||||
|
||||
@@ -33,102 +33,6 @@ const themeColors = {
|
||||
purple: '#800080',
|
||||
cyan: '#00FFFF',
|
||||
},
|
||||
traceDetailColorsV3: {
|
||||
// Blues
|
||||
blue1: '#2F80ED',
|
||||
blue2: '#3366E6',
|
||||
blue3: '#4682B4',
|
||||
blue4: '#1F63E0',
|
||||
blue5: '#3A7AED',
|
||||
blue6: '#5A9DF5',
|
||||
blue7: '#2874A6',
|
||||
blue8: '#2E86C1',
|
||||
blue9: '#3498DB',
|
||||
blue10: '#1E90FF',
|
||||
blue11: '#4169E1',
|
||||
|
||||
// Cyans / Teals
|
||||
cyan1: '#00CEC9',
|
||||
cyan2: '#22A6F2',
|
||||
cyan3: '#00B0AA',
|
||||
cyan4: '#33D6C2',
|
||||
cyan5: '#66E9DA',
|
||||
cyan6: '#48DBFB',
|
||||
cyan7: '#00BFFF',
|
||||
cyan8: '#63B8FF',
|
||||
teal1: '#009688',
|
||||
teal2: '#1ABC9C',
|
||||
teal3: '#48C9B0',
|
||||
teal4: '#76D7C4',
|
||||
teal5: '#20B2AA',
|
||||
|
||||
// Greens
|
||||
green1: '#27AE60',
|
||||
green2: '#3CB371',
|
||||
green3: '#1E8449',
|
||||
green4: '#2ECC71',
|
||||
green5: '#58D68D',
|
||||
green6: '#229954',
|
||||
green7: '#52BE80',
|
||||
green8: '#82E0AA',
|
||||
green9: '#73C6B6',
|
||||
|
||||
// Limes
|
||||
lime1: '#A3E635',
|
||||
lime2: '#B9F18D',
|
||||
lime3: '#84CC16',
|
||||
lime4: '#65A30D',
|
||||
|
||||
// Yellows
|
||||
yellow1: '#F1C40F',
|
||||
yellow2: '#F7DC6F',
|
||||
yellow3: '#F9E79F',
|
||||
yellow4: '#F4D03F',
|
||||
yellow5: '#D4AC0D',
|
||||
|
||||
// Golds / Ambers
|
||||
gold1: '#F2C94C',
|
||||
gold2: '#FFD93D',
|
||||
gold3: '#FFCA28',
|
||||
gold4: '#B7950B',
|
||||
gold5: '#D4A017',
|
||||
|
||||
// Oranges (non-red)
|
||||
orange1: '#F39C12',
|
||||
orange2: '#E67E22',
|
||||
orange3: '#F5B041',
|
||||
orange4: '#D35400',
|
||||
orange5: '#EB984E',
|
||||
orange6: '#FAD7A0',
|
||||
|
||||
// Purples / Violets
|
||||
purple1: '#BB6BD9',
|
||||
purple2: '#9B51E0',
|
||||
purple3: '#DA77F2',
|
||||
purple4: '#C77DFF',
|
||||
purple5: '#6C5CE7',
|
||||
purple6: '#8E44AD',
|
||||
purple7: '#9B59B6',
|
||||
purple8: '#BB8FCE',
|
||||
purple9: '#7D3C98',
|
||||
purple10: '#A569BD',
|
||||
|
||||
// Lavenders
|
||||
lavender1: '#AF7AC5',
|
||||
lavender2: '#C39BD3',
|
||||
lavender3: '#D2B4DE',
|
||||
|
||||
// Pinks / Magentas
|
||||
pink1: '#E91E8C',
|
||||
pink2: '#FF6FD8',
|
||||
pink3: '#F06292',
|
||||
pink4: '#CE93D8',
|
||||
|
||||
// Salmons / Corals (distinct from error red)
|
||||
salmon1: '#FF8A65',
|
||||
salmon2: '#FFAB91',
|
||||
salmon3: '#E0876A',
|
||||
},
|
||||
chartcolors: {
|
||||
// Blues (3)
|
||||
dodgerBlue: '#2F80ED',
|
||||
|
||||
@@ -196,12 +196,12 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
|
||||
useDashboardStore.setState({
|
||||
selectedDashboard: (getDashboardById.data as unknown) as Dashboard,
|
||||
dashboardData: (getDashboardById.data as unknown) as Dashboard,
|
||||
layouts: [],
|
||||
panelMap: {},
|
||||
setPanelMap: jest.fn(),
|
||||
setLayouts: jest.fn(),
|
||||
setSelectedDashboard: jest.fn(),
|
||||
setDashboardData: jest.fn(),
|
||||
columnWidths: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -78,12 +78,12 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
const {
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
panelMap,
|
||||
setPanelMap,
|
||||
layouts,
|
||||
setLayouts,
|
||||
setSelectedDashboard,
|
||||
setDashboardData,
|
||||
} = useDashboardStore();
|
||||
|
||||
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
|
||||
@@ -98,10 +98,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
|
||||
const isPublicDashboardEnabled = isCloudUser || isEnterpriseSelfHostedUser;
|
||||
|
||||
const selectedData = selectedDashboard
|
||||
const selectedData = dashboardData
|
||||
? {
|
||||
...selectedDashboard.data,
|
||||
uuid: selectedDashboard.id,
|
||||
...dashboardData.data,
|
||||
uuid: dashboardData.id,
|
||||
}
|
||||
: ({} as DashboardData);
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
@@ -133,8 +133,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
|
||||
let isAuthor = false;
|
||||
|
||||
if (selectedDashboard && user && user.email) {
|
||||
isAuthor = selectedDashboard?.createdBy === user?.email;
|
||||
if (dashboardData && user && user.email) {
|
||||
isAuthor = dashboardData?.createdBy === user?.email;
|
||||
}
|
||||
|
||||
let permissions: ComponentTypes[] = ['add_panel'];
|
||||
@@ -146,7 +146,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const userRole: ROLES | null =
|
||||
selectedDashboard?.createdBy === user?.email
|
||||
dashboardData?.createdBy === user?.email
|
||||
? (USER_ROLES.AUTHOR as ROLES)
|
||||
: user.role;
|
||||
|
||||
@@ -155,9 +155,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const onEmptyWidgetHandler = useCallback(() => {
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
logEvent('Dashboard Detail: Add new panel clicked', {
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
numberOfPanels: selectedDashboard?.data.widgets?.length,
|
||||
dashboardId: dashboardData?.id,
|
||||
dashboardName: dashboardData?.data.title,
|
||||
numberOfPanels: dashboardData?.data.widgets?.length,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setIsPanelTypeSelectionModalOpen]);
|
||||
@@ -168,14 +168,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
};
|
||||
|
||||
const onNameChangeHandler = (): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
const updatedDashboard: Props = {
|
||||
id: selectedDashboard.id,
|
||||
id: dashboardData.id,
|
||||
|
||||
data: {
|
||||
...selectedDashboard.data,
|
||||
...dashboardData.data,
|
||||
title: updatedTitle,
|
||||
},
|
||||
};
|
||||
@@ -186,7 +186,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
});
|
||||
setIsRenameDashboardOpen(false);
|
||||
if (updatedDashboard.data) {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
setDashboardData(updatedDashboard.data);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
@@ -203,10 +203,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
// the context value is sometimes not available during the initial render
|
||||
// due to which the updatedTitle is set to some previous value
|
||||
useEffect(() => {
|
||||
if (selectedDashboard) {
|
||||
setUpdatedTitle(selectedDashboard.data.title);
|
||||
if (dashboardData) {
|
||||
setUpdatedTitle(dashboardData.data.title);
|
||||
}
|
||||
}, [selectedDashboard]);
|
||||
}, [dashboardData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
@@ -227,7 +227,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
}, [state.error, state.value, t, notifications]);
|
||||
|
||||
function handleAddRow(): void {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
const id = uuid();
|
||||
@@ -246,10 +246,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
}
|
||||
|
||||
const updatedDashboard: Props = {
|
||||
id: selectedDashboard.id,
|
||||
id: dashboardData.id,
|
||||
|
||||
data: {
|
||||
...selectedDashboard.data,
|
||||
...dashboardData.data,
|
||||
layout: [
|
||||
{
|
||||
i: id,
|
||||
@@ -265,7 +265,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
],
|
||||
panelMap: { ...panelMap, [id]: newRowWidgetMap },
|
||||
widgets: [
|
||||
...(selectedDashboard.data.widgets || []),
|
||||
...(dashboardData.data.widgets || []),
|
||||
{
|
||||
id,
|
||||
title: sectionName,
|
||||
@@ -282,7 +282,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
if (updatedDashboard.data.data.layout) {
|
||||
setLayouts(sortLayout(updatedDashboard.data.data.layout));
|
||||
}
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
setDashboardData(updatedDashboard.data);
|
||||
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
|
||||
}
|
||||
|
||||
@@ -299,8 +299,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
error: errorPublicDashboardData,
|
||||
isError: isErrorPublicDashboardData,
|
||||
} = useGetPublicDashboardMeta(
|
||||
selectedDashboard?.id || '',
|
||||
!!selectedDashboard?.id && isPublicDashboardEnabled,
|
||||
dashboardData?.id || '',
|
||||
!!dashboardData?.id && isPublicDashboardEnabled,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -378,14 +378,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
|
||||
<Tooltip
|
||||
title={
|
||||
selectedDashboard?.createdBy === 'integration' &&
|
||||
dashboardData?.createdBy === 'integration' &&
|
||||
'Dashboards created by integrations cannot be unlocked'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LockKeyhole size={14} />}
|
||||
disabled={selectedDashboard?.createdBy === 'integration'}
|
||||
disabled={dashboardData?.createdBy === 'integration'}
|
||||
onClick={handleLockDashboardToggle}
|
||||
data-testid="lock-unlock-dashboard"
|
||||
>
|
||||
@@ -457,9 +457,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
</section>
|
||||
<section className="delete-dashboard">
|
||||
<DeleteButton
|
||||
createdBy={selectedDashboard?.createdBy || ''}
|
||||
name={selectedDashboard?.data.title || ''}
|
||||
id={String(selectedDashboard?.id) || ''}
|
||||
createdBy={dashboardData?.createdBy || ''}
|
||||
name={dashboardData?.data.title || ''}
|
||||
id={String(dashboardData?.id) || ''}
|
||||
isLocked={isDashboardLocked}
|
||||
routeToListPage
|
||||
/>
|
||||
|
||||
@@ -239,7 +239,7 @@ function VariableItem({
|
||||
|
||||
const [selectedWidgets, setSelectedWidgets] = useState<string[]>([]);
|
||||
|
||||
const { selectedDashboard } = useDashboardStore();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
const widgetsByDynamicVariableId = useWidgetsByDynamicVariableId();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -248,7 +248,7 @@ function VariableItem({
|
||||
} else if (dynamicVariablesSelectedValue?.name) {
|
||||
const widgets = getWidgetsHavingDynamicVariableAttribute(
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
(selectedDashboard?.data?.widgets?.filter(
|
||||
(dashboardData?.data?.widgets?.filter(
|
||||
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
|
||||
) || []) as Widgets[],
|
||||
variableData.name,
|
||||
@@ -257,7 +257,7 @@ function VariableItem({
|
||||
}
|
||||
}, [
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
variableData.id,
|
||||
variableData.name,
|
||||
widgetsByDynamicVariableId,
|
||||
|
||||
@@ -12,17 +12,17 @@ export function WidgetSelector({
|
||||
selectedWidgets: string[];
|
||||
setSelectedWidgets: (widgets: string[]) => void;
|
||||
}): JSX.Element {
|
||||
const { selectedDashboard } = useDashboardStore();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
|
||||
// Get layout IDs for cross-referencing
|
||||
const layoutIds = new Set(
|
||||
(selectedDashboard?.data?.layout || []).map((item) => item.i),
|
||||
(dashboardData?.data?.layout || []).map((item) => item.i),
|
||||
);
|
||||
|
||||
// Filter and deduplicate widgets by ID, keeping only those with layout entries
|
||||
// and excluding row widgets since they are not panels that can have variables
|
||||
const widgets = Object.values(
|
||||
(selectedDashboard?.data?.widgets || []).reduce(
|
||||
(dashboardData?.data?.widgets || []).reduce(
|
||||
(acc: Record<string, WidgetRow | Widgets>, widget: WidgetRow | Widgets) => {
|
||||
if (
|
||||
widget.id &&
|
||||
|
||||
@@ -87,7 +87,7 @@ function VariablesSettings({
|
||||
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
|
||||
const { selectedDashboard, setSelectedDashboard } = useDashboardStore();
|
||||
const { dashboardData, setDashboardData } = useDashboardStore();
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
@@ -173,7 +173,7 @@ function VariablesSettings({
|
||||
widgetIds?: string[],
|
||||
applyToAll?: boolean,
|
||||
): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -181,16 +181,16 @@ function VariablesSettings({
|
||||
(currentRequestedId &&
|
||||
updatedVariablesData[currentRequestedId || '']?.type === 'DYNAMIC' &&
|
||||
addDynamicVariableToPanels(
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
updatedVariablesData[currentRequestedId || ''],
|
||||
widgetIds,
|
||||
applyToAll,
|
||||
)) ||
|
||||
selectedDashboard;
|
||||
dashboardData;
|
||||
|
||||
updateMutation.mutateAsync(
|
||||
{
|
||||
id: selectedDashboard.id,
|
||||
id: dashboardData.id,
|
||||
|
||||
data: {
|
||||
...newDashboard.data,
|
||||
@@ -200,7 +200,7 @@ function VariablesSettings({
|
||||
{
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
setDashboardData(updatedDashboard.data);
|
||||
notifications.success({
|
||||
message: t('variable_updated_successfully'),
|
||||
});
|
||||
|
||||
@@ -15,11 +15,11 @@ import './GeneralSettings.styles.scss';
|
||||
const { Option } = Select;
|
||||
|
||||
function GeneralDashboardSettings(): JSX.Element {
|
||||
const { selectedDashboard, setSelectedDashboard } = useDashboardStore();
|
||||
const { dashboardData, setDashboardData } = useDashboardStore();
|
||||
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const selectedData = selectedDashboard?.data;
|
||||
const selectedData = dashboardData?.data;
|
||||
|
||||
const { title = '', tags = [], description = '', image = Base64Icons[0] } =
|
||||
selectedData || {};
|
||||
@@ -37,15 +37,15 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const onSaveHandler = (): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDashboardMutation.mutate(
|
||||
{
|
||||
id: selectedDashboard.id,
|
||||
id: dashboardData.id,
|
||||
data: {
|
||||
...selectedDashboard.data,
|
||||
...dashboardData.data,
|
||||
description: updatedDescription,
|
||||
tags: updatedTags,
|
||||
title: updatedTitle,
|
||||
@@ -55,7 +55,7 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
{
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
setDashboardData(updatedDashboard.data);
|
||||
}
|
||||
},
|
||||
onError: () => {},
|
||||
|
||||
@@ -41,7 +41,7 @@ const DASHBOARD_VARIABLES_WARNING =
|
||||
// Use wildcard pattern to match both relative and absolute URLs in MSW
|
||||
const publicDashboardURL = `*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`;
|
||||
|
||||
const mockSelectedDashboard = {
|
||||
const mockDashboardData = {
|
||||
id: MOCK_DASHBOARD_ID,
|
||||
data: {
|
||||
title: 'Test Dashboard',
|
||||
@@ -70,7 +70,7 @@ beforeEach(() => {
|
||||
|
||||
// Mock useDashboardStore
|
||||
mockUseDashboard.mockReturnValue(({
|
||||
selectedDashboard: mockSelectedDashboard,
|
||||
dashboardData: mockDashboardData,
|
||||
} as unknown) as ReturnType<typeof useDashboardStore>);
|
||||
|
||||
// Mock useCopyToClipboard
|
||||
|
||||
@@ -60,7 +60,7 @@ function PublicDashboardSetting(): JSX.Element {
|
||||
const [defaultTimeRange, setDefaultTimeRange] = useState(DEFAULT_TIME_RANGE);
|
||||
const [, setCopyPublicDashboardURL] = useCopyToClipboard();
|
||||
|
||||
const { selectedDashboard } = useDashboardStore();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
@@ -85,8 +85,8 @@ function PublicDashboardSetting(): JSX.Element {
|
||||
refetch: refetchPublicDashboard,
|
||||
error: errorPublicDashboard,
|
||||
} = useGetPublicDashboardMeta(
|
||||
selectedDashboard?.id || '',
|
||||
!!selectedDashboard?.id && isPublicDashboardEnabled,
|
||||
dashboardData?.id || '',
|
||||
!!dashboardData?.id && isPublicDashboardEnabled,
|
||||
);
|
||||
|
||||
const isPublicDashboard = !!publicDashboardData?.publicPath;
|
||||
@@ -155,36 +155,36 @@ function PublicDashboardSetting(): JSX.Element {
|
||||
});
|
||||
|
||||
const handleCreatePublicDashboard = (): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
createPublicDashboard({
|
||||
dashboardId: selectedDashboard.id,
|
||||
dashboardId: dashboardData.id,
|
||||
timeRangeEnabled,
|
||||
defaultTimeRange,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdatePublicDashboard = (): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
updatePublicDashboard({
|
||||
dashboardId: selectedDashboard.id,
|
||||
dashboardId: dashboardData.id,
|
||||
timeRangeEnabled,
|
||||
defaultTimeRange,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevokePublicDashboardAccess = (): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
revokePublicDashboardAccess({
|
||||
id: selectedDashboard.id,
|
||||
id: dashboardData.id,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ import VariableItem from './VariableItem';
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
|
||||
function DashboardVariableSelection(): JSX.Element | null {
|
||||
const { dashboardId, setSelectedDashboard } = useDashboardStore(
|
||||
const { dashboardId, setDashboardData } = useDashboardStore(
|
||||
useShallow((s) => ({
|
||||
dashboardId: s.selectedDashboard?.id ?? '',
|
||||
setSelectedDashboard: s.setSelectedDashboard,
|
||||
dashboardId: s.dashboardData?.id ?? '',
|
||||
setDashboardData: s.setDashboardData,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -99,7 +99,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
|
||||
// Synchronously update the external store with the new variable value so that
|
||||
// child variables see the updated parent value when they refetch, rather than
|
||||
// waiting for setSelectedDashboard → useEffect → updateDashboardVariablesStore.
|
||||
// waiting for setDashboardData → useEffect → updateDashboardVariablesStore.
|
||||
const updatedVariables = { ...dashboardVariables };
|
||||
if (updatedVariables[id]) {
|
||||
updatedVariables[id] = {
|
||||
@@ -119,7 +119,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
}
|
||||
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
|
||||
|
||||
setSelectedDashboard((prev) => {
|
||||
setDashboardData((prev) => {
|
||||
if (prev) {
|
||||
const oldVariables = { ...prev?.data.variables };
|
||||
// this is added to handle case where we have two different
|
||||
@@ -157,7 +157,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
// Safe to call synchronously now that the store already has the updated value.
|
||||
enqueueDescendantsOfVariable(name);
|
||||
},
|
||||
[dashboardId, dashboardVariables, updateUrlVariable, setSelectedDashboard],
|
||||
[dashboardId, dashboardVariables, updateUrlVariable, setDashboardData],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,11 +30,11 @@ const mockVariableItemCallbacks: {
|
||||
} = {};
|
||||
|
||||
// Mock providers/Dashboard/Dashboard
|
||||
const mockSetSelectedDashboard = jest.fn();
|
||||
const mockSetDashboardData = jest.fn();
|
||||
const mockUpdateLocalStorageDashboardVariables = jest.fn();
|
||||
interface MockDashboardStoreState {
|
||||
selectedDashboard?: { id: string };
|
||||
setSelectedDashboard: typeof mockSetSelectedDashboard;
|
||||
dashboardData?: { id: string };
|
||||
setDashboardData: typeof mockSetDashboardData;
|
||||
updateLocalStorageDashboardVariables: typeof mockUpdateLocalStorageDashboardVariables;
|
||||
}
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
@@ -42,8 +42,8 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
selector?: (s: Record<string, unknown>) => MockDashboardStoreState,
|
||||
): MockDashboardStoreState => {
|
||||
const state = {
|
||||
selectedDashboard: { id: 'dash-1' },
|
||||
setSelectedDashboard: mockSetSelectedDashboard,
|
||||
dashboardData: { id: 'dash-1' },
|
||||
setDashboardData: mockSetDashboardData,
|
||||
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
|
||||
@@ -38,15 +38,11 @@ interface UseDashboardVariableUpdateReturn {
|
||||
}
|
||||
|
||||
export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn => {
|
||||
const {
|
||||
dashboardId,
|
||||
selectedDashboard,
|
||||
setSelectedDashboard,
|
||||
} = useDashboardStore(
|
||||
const { dashboardId, dashboardData, setDashboardData } = useDashboardStore(
|
||||
useShallow((s) => ({
|
||||
dashboardId: s.selectedDashboard?.id ?? '',
|
||||
selectedDashboard: s.selectedDashboard,
|
||||
setSelectedDashboard: s.setSelectedDashboard,
|
||||
dashboardId: s.dashboardData?.id ?? '',
|
||||
dashboardData: s.dashboardData,
|
||||
setDashboardData: s.setDashboardData,
|
||||
})),
|
||||
);
|
||||
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
|
||||
@@ -74,8 +70,8 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
|
||||
isDynamic,
|
||||
);
|
||||
|
||||
if (selectedDashboard) {
|
||||
setSelectedDashboard((prev) => {
|
||||
if (dashboardData) {
|
||||
setDashboardData((prev) => {
|
||||
if (prev) {
|
||||
const oldVariables = prev?.data.variables;
|
||||
// this is added to handle case where we have two different
|
||||
@@ -110,7 +106,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
|
||||
}
|
||||
}
|
||||
},
|
||||
[dashboardId, selectedDashboard, setSelectedDashboard],
|
||||
[dashboardId, dashboardData, setDashboardData],
|
||||
);
|
||||
|
||||
const updateVariables = useCallback(
|
||||
@@ -120,23 +116,23 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
|
||||
widgetIds?: string[],
|
||||
applyToAll?: boolean,
|
||||
): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDashboard =
|
||||
(currentRequestedId &&
|
||||
addDynamicVariableToPanels(
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
updatedVariablesData[currentRequestedId || ''],
|
||||
widgetIds,
|
||||
applyToAll,
|
||||
)) ||
|
||||
selectedDashboard;
|
||||
dashboardData;
|
||||
|
||||
updateMutation.mutateAsync(
|
||||
{
|
||||
id: selectedDashboard.id,
|
||||
id: dashboardData.id,
|
||||
|
||||
data: {
|
||||
...newDashboard.data,
|
||||
@@ -146,7 +142,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
|
||||
{
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
setDashboardData(updatedDashboard.data);
|
||||
// notifications.success({
|
||||
// message: t('variable_updated_successfully'),
|
||||
// });
|
||||
@@ -155,12 +151,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
|
||||
},
|
||||
);
|
||||
},
|
||||
[
|
||||
selectedDashboard,
|
||||
addDynamicVariableToPanels,
|
||||
updateMutation,
|
||||
setSelectedDashboard,
|
||||
],
|
||||
[dashboardData, addDynamicVariableToPanels, updateMutation, setDashboardData],
|
||||
);
|
||||
|
||||
const createVariable = useCallback(
|
||||
@@ -172,13 +163,13 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
|
||||
source: 'logs' | 'traces' | 'metrics' | 'all sources' = 'all sources',
|
||||
// widgetId?: string,
|
||||
): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
console.warn('No dashboard selected for variable creation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current dashboard variables
|
||||
const currentVariables = selectedDashboard.data.variables || {};
|
||||
const currentVariables = dashboardData.data.variables || {};
|
||||
|
||||
// Create tableRowData like Dashboard Settings does
|
||||
const tableRowData = [];
|
||||
@@ -234,7 +225,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
|
||||
const updatedVariables = convertVariablesToDbFormat(tableRowData);
|
||||
updateVariables(updatedVariables, newVariable.id, [], false);
|
||||
},
|
||||
[selectedDashboard, updateVariables],
|
||||
[dashboardData, updateVariables],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -47,12 +47,12 @@ const mockDashboard = {
|
||||
};
|
||||
|
||||
// Mock the dashboard provider with stable functions to prevent infinite loops
|
||||
const mockSetSelectedDashboard = jest.fn();
|
||||
const mockSetDashboardData = jest.fn();
|
||||
const mockUpdateLocalStorageDashboardVariables = jest.fn();
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (): any => ({
|
||||
selectedDashboard: mockDashboard,
|
||||
setSelectedDashboard: mockSetSelectedDashboard,
|
||||
dashboardData: mockDashboard,
|
||||
setDashboardData: mockSetDashboardData,
|
||||
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -58,7 +58,7 @@ const mockDashboard = {
|
||||
// Mock dependencies
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (): any => ({
|
||||
selectedDashboard: mockDashboard,
|
||||
dashboardData: mockDashboard,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('Panel Management Tests', () => {
|
||||
// Temporarily mock the dashboard
|
||||
jest.doMock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (): any => ({
|
||||
selectedDashboard: modifiedDashboard,
|
||||
dashboardData: modifiedDashboard,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ import './DashboardBreadcrumbs.styles.scss';
|
||||
|
||||
function DashboardBreadcrumbs(): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { selectedDashboard } = useDashboardStore();
|
||||
const updatedAtRef = useRef(selectedDashboard?.updatedAt);
|
||||
const { dashboardData } = useDashboardStore();
|
||||
const updatedAtRef = useRef(dashboardData?.updatedAt);
|
||||
|
||||
const selectedData = selectedDashboard
|
||||
const selectedData = dashboardData
|
||||
? {
|
||||
...selectedDashboard.data,
|
||||
uuid: selectedDashboard.id,
|
||||
...dashboardData.data,
|
||||
uuid: dashboardData.id,
|
||||
}
|
||||
: ({} as DashboardData);
|
||||
|
||||
@@ -31,7 +31,7 @@ function DashboardBreadcrumbs(): JSX.Element {
|
||||
);
|
||||
|
||||
const hasDashboardBeenUpdated =
|
||||
selectedDashboard?.updatedAt !== updatedAtRef.current;
|
||||
dashboardData?.updatedAt !== updatedAtRef.current;
|
||||
if (!hasDashboardBeenUpdated && dashboardsListQueryParamsString) {
|
||||
safeNavigate({
|
||||
pathname: ROUTES.ALL_DASHBOARD,
|
||||
@@ -40,7 +40,7 @@ function DashboardBreadcrumbs(): JSX.Element {
|
||||
} else {
|
||||
safeNavigate(ROUTES.ALL_DASHBOARD);
|
||||
}
|
||||
}, [safeNavigate, selectedDashboard?.updatedAt]);
|
||||
}, [safeNavigate, dashboardData?.updatedAt]);
|
||||
|
||||
return (
|
||||
<div className="dashboard-breadcrumbs">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import {
|
||||
@@ -30,6 +30,7 @@ export default function ChartWrapper({
|
||||
onDestroy = noop,
|
||||
children,
|
||||
layoutChildren,
|
||||
yAxisUnit,
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
'data-testid': testId,
|
||||
@@ -62,6 +63,13 @@ export default function ChartWrapper({
|
||||
[customTooltip],
|
||||
);
|
||||
|
||||
const syncMetadata = useMemo(
|
||||
() => ({
|
||||
yAxisUnit,
|
||||
}),
|
||||
[yAxisUnit],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlotContextProvider>
|
||||
<ChartLayout
|
||||
@@ -99,6 +107,7 @@ export default function ChartWrapper({
|
||||
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
|
||||
)}
|
||||
syncKey={syncKey}
|
||||
syncMetadata={syncMetadata}
|
||||
render={renderTooltipCallback}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
/>
|
||||
|
||||
@@ -24,13 +24,12 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
|
||||
}
|
||||
const tooltipProps: HistogramTooltipProps = {
|
||||
...props,
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
};
|
||||
return <HistogramTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,10 +12,7 @@ interface BaseChartProps {
|
||||
height: number;
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
timezone?: Timezone;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
@@ -32,18 +29,31 @@ interface UPlotBasedChartProps {
|
||||
layoutChildren?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface UPlotChartDataProps {
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps {}
|
||||
UPlotBasedChartProps,
|
||||
UPlotChartDataProps {
|
||||
timezone?: Timezone;
|
||||
}
|
||||
|
||||
export interface HistogramChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps {
|
||||
UPlotBasedChartProps,
|
||||
UPlotChartDataProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
|
||||
export interface BarChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps,
|
||||
UPlotChartDataProps {
|
||||
isStackedBarChart?: boolean;
|
||||
timezone?: Timezone;
|
||||
}
|
||||
|
||||
export type ChartProps =
|
||||
|
||||
@@ -35,15 +35,15 @@ jest.mock('lib/uPlotV2/hooks/useLegendsSync', () => ({
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (
|
||||
selector?: (s: {
|
||||
selectedDashboard: { locked: boolean } | undefined;
|
||||
}) => { selectedDashboard: { locked: boolean } },
|
||||
): { selectedDashboard: { locked: boolean } } => {
|
||||
const mockState = { selectedDashboard: { locked: false } };
|
||||
dashboardData: { locked: boolean } | undefined;
|
||||
}) => { dashboardData: { locked: boolean } },
|
||||
): { dashboardData: { locked: boolean } } => {
|
||||
const mockState = { dashboardData: { locked: false } };
|
||||
return selector ? selector(mockState) : mockState;
|
||||
},
|
||||
selectIsDashboardLocked: (s: {
|
||||
selectedDashboard: { locked: boolean } | undefined;
|
||||
}): boolean => s.selectedDashboard?.locked ?? false,
|
||||
dashboardData: { locked: boolean } | undefined;
|
||||
}): boolean => s.dashboardData?.locked ?? false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
|
||||
@@ -123,13 +123,13 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
}}
|
||||
plotRef={onPlotRef}
|
||||
onDestroy={onPlotDestroy}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
isStackedBarChart={widget.stackedBarChart ?? false}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
>
|
||||
<ContextMenu
|
||||
|
||||
@@ -3,8 +3,6 @@ import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import Histogram from '../../charts/Histogram/Histogram';
|
||||
@@ -29,7 +27,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const config = useMemo(() => {
|
||||
return prepareHistogramPanelConfig({
|
||||
@@ -92,11 +89,9 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onDestroy={(): void => {
|
||||
uPlotRef.current = null;
|
||||
}}
|
||||
isQueriesMerged={widget.mergeAllActiveQueries}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
syncMode={DashboardCursorSync.Crosshair}
|
||||
timezone={timezone}
|
||||
isQueriesMerged={widget.mergeAllActiveQueries}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
|
||||
@@ -48,8 +48,8 @@ jest.mock(
|
||||
{JSON.stringify({
|
||||
legendPosition: props.legendConfig?.position,
|
||||
isQueriesMerged: props.isQueriesMerged,
|
||||
yAxisUnit: props.yAxisUnit,
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
yAxisUnit: props?.yAxisUnit,
|
||||
decimalPrecision: props?.decimalPrecision,
|
||||
})}
|
||||
</div>
|
||||
{props.layoutChildren}
|
||||
|
||||
@@ -112,9 +112,9 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
}}
|
||||
timezone={timezone}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
|
||||
@@ -123,7 +123,6 @@
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
max-width: 825px;
|
||||
gap: 25px;
|
||||
|
||||
@@ -24,9 +24,7 @@ function ExportPanelContainer({
|
||||
}: ExportPanelProps): JSX.Element {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -55,17 +53,17 @@ function ExportPanelContainer({
|
||||
|
||||
const handleExportClick = useCallback((): void => {
|
||||
const currentSelectedDashboard = data?.data?.find(
|
||||
({ id }) => id === selectedDashboardId,
|
||||
({ id }) => id === dashboardId,
|
||||
);
|
||||
|
||||
onExport(currentSelectedDashboard || null, false);
|
||||
}, [data, selectedDashboardId, onExport]);
|
||||
}, [data, dashboardId, onExport]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedDashboardValue: string): void => {
|
||||
setSelectedDashboardId(selectedDashboardValue);
|
||||
(selectedDashboardId: string): void => {
|
||||
setDashboardId(selectedDashboardId);
|
||||
},
|
||||
[setSelectedDashboardId],
|
||||
[setDashboardId],
|
||||
);
|
||||
|
||||
const handleNewDashboard = useCallback(async () => {
|
||||
@@ -85,10 +83,7 @@ function ExportPanelContainer({
|
||||
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
|
||||
|
||||
const isDisabled =
|
||||
isAllDashboardsLoading ||
|
||||
!options?.length ||
|
||||
!selectedDashboardId ||
|
||||
isLoading;
|
||||
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
|
||||
|
||||
return (
|
||||
<Wrapper direction="vertical">
|
||||
@@ -101,7 +96,7 @@ function ExportPanelContainer({
|
||||
showSearch
|
||||
loading={isDashboardLoading}
|
||||
disabled={isDashboardLoading}
|
||||
value={selectedDashboardId}
|
||||
value={dashboardId}
|
||||
onSelect={handleSelect}
|
||||
filterOption={filterOptions}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function DashboardEmptyState(): JSX.Element {
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
|
||||
const { selectedDashboard } = useDashboardStore();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
|
||||
|
||||
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
|
||||
@@ -43,7 +43,7 @@ export default function DashboardEmptyState(): JSX.Element {
|
||||
}
|
||||
|
||||
const userRole: ROLES | null =
|
||||
selectedDashboard?.createdBy === user?.email
|
||||
dashboardData?.createdBy === user?.email
|
||||
? (USER_ROLES.AUTHOR as ROLES)
|
||||
: user.role;
|
||||
|
||||
@@ -52,9 +52,9 @@ export default function DashboardEmptyState(): JSX.Element {
|
||||
const onEmptyWidgetHandler = useCallback(() => {
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
logEvent('Dashboard Detail: Add new panel clicked', {
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
numberOfPanels: selectedDashboard?.data.widgets?.length,
|
||||
dashboardId: dashboardData?.id,
|
||||
dashboardName: dashboardData?.data.title,
|
||||
numberOfPanels: dashboardData?.data.widgets?.length,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setIsPanelTypeSelectionModalOpen]);
|
||||
|
||||
@@ -91,7 +91,7 @@ function FullView({
|
||||
setCurrentGraphRef(fullViewRef);
|
||||
}, [setCurrentGraphRef]);
|
||||
|
||||
const { selectedDashboard, setColumnWidths } = useDashboardStore();
|
||||
const { dashboardData, setColumnWidths } = useDashboardStore();
|
||||
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
|
||||
|
||||
const onColumnWidthsChange = useCallback(
|
||||
@@ -166,7 +166,7 @@ function FullView({
|
||||
enableDrillDown,
|
||||
widget,
|
||||
setRequestData,
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
selectedPanelType,
|
||||
});
|
||||
|
||||
@@ -344,7 +344,7 @@ function FullView({
|
||||
<>
|
||||
<QueryBuilderV2
|
||||
panelType={selectedPanelType}
|
||||
version={selectedDashboard?.data?.version || 'v3'}
|
||||
version={dashboardData?.data?.version || 'v3'}
|
||||
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
|
||||
signalSourceChangeEnabled
|
||||
// filterConfigs={filterConfigs}
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface DrilldownQueryProps {
|
||||
widget: Widgets;
|
||||
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||
enableDrillDown: boolean;
|
||||
selectedDashboard: Dashboard | undefined;
|
||||
dashboardData: Dashboard | undefined;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ const useDrilldown = ({
|
||||
enableDrillDown,
|
||||
widget,
|
||||
setRequestData,
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
selectedPanelType,
|
||||
}: DrilldownQueryProps): UseDrilldownReturn => {
|
||||
const isMounted = useRef(false);
|
||||
@@ -60,11 +60,11 @@ const useDrilldown = ({
|
||||
isMounted.current = true;
|
||||
}, [widget, enableDrillDown, compositeQuery, redirectWithQueryBuilderData]);
|
||||
|
||||
const dashboardEditView = selectedDashboard?.id
|
||||
const dashboardEditView = dashboardData?.id
|
||||
? generateExportToDashboardLink({
|
||||
query: currentQuery,
|
||||
panelType: selectedPanelType,
|
||||
dashboardId: selectedDashboard?.id || '',
|
||||
dashboardId: dashboardData?.id || '',
|
||||
widgetId: widget.id,
|
||||
})
|
||||
: '';
|
||||
|
||||
@@ -163,13 +163,13 @@ const mockProps: WidgetGraphComponentProps = {
|
||||
// Mock useDashabord hook
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (): any => ({
|
||||
selectedDashboard: {
|
||||
dashboardData: {
|
||||
data: {
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
setLayouts: jest.fn(),
|
||||
setSelectedDashboard: jest.fn(),
|
||||
setDashboardData: jest.fn(),
|
||||
setColumnWidths: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -103,8 +103,8 @@ function WidgetGraphComponent({
|
||||
|
||||
const {
|
||||
setLayouts,
|
||||
selectedDashboard,
|
||||
setSelectedDashboard,
|
||||
dashboardData,
|
||||
setDashboardData,
|
||||
setColumnWidths,
|
||||
} = useDashboardStore();
|
||||
|
||||
@@ -125,33 +125,33 @@ function WidgetGraphComponent({
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const onDeleteHandler = (): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
|
||||
const updatedWidgets = dashboardData?.data?.widgets?.filter(
|
||||
(e) => e.id !== widget.id,
|
||||
);
|
||||
|
||||
const updatedLayout =
|
||||
selectedDashboard.data.layout?.filter((e) => e.i !== widget.id) || [];
|
||||
dashboardData.data.layout?.filter((e) => e.i !== widget.id) || [];
|
||||
|
||||
const updatedSelectedDashboard: Props = {
|
||||
const updatedDashboardData: Props = {
|
||||
data: {
|
||||
...selectedDashboard.data,
|
||||
...dashboardData.data,
|
||||
widgets: updatedWidgets,
|
||||
layout: updatedLayout,
|
||||
},
|
||||
id: selectedDashboard.id,
|
||||
id: dashboardData.id,
|
||||
};
|
||||
|
||||
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
|
||||
updateDashboardMutation.mutateAsync(updatedDashboardData, {
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (setLayouts) {
|
||||
setLayouts(updatedDashboard.data?.data?.layout || []);
|
||||
}
|
||||
if (setSelectedDashboard && updatedDashboard.data) {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
if (setDashboardData && updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
}
|
||||
setDeleteModal(false);
|
||||
},
|
||||
@@ -159,35 +159,35 @@ function WidgetGraphComponent({
|
||||
};
|
||||
|
||||
const onCloneHandler = async (): Promise<void> => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uuid = v4();
|
||||
|
||||
// this is added to make sure the cloned panel is of the same dimensions as the original one
|
||||
const originalPanelLayout = selectedDashboard.data.layout?.find(
|
||||
const originalPanelLayout = dashboardData.data.layout?.find(
|
||||
(l) => l.i === widget.id,
|
||||
);
|
||||
|
||||
const newLayoutItem = placeWidgetAtBottom(
|
||||
uuid,
|
||||
selectedDashboard?.data.layout || [],
|
||||
dashboardData?.data.layout || [],
|
||||
originalPanelLayout?.w || 6,
|
||||
originalPanelLayout?.h || 6,
|
||||
);
|
||||
|
||||
const layout = [...(selectedDashboard.data.layout || []), newLayoutItem];
|
||||
const layout = [...(dashboardData.data.layout || []), newLayoutItem];
|
||||
|
||||
updateDashboardMutation.mutateAsync(
|
||||
{
|
||||
id: selectedDashboard.id,
|
||||
id: dashboardData.id,
|
||||
|
||||
data: {
|
||||
...selectedDashboard.data,
|
||||
...dashboardData.data,
|
||||
layout,
|
||||
widgets: [
|
||||
...(selectedDashboard.data.widgets || []),
|
||||
...(dashboardData.data.widgets || []),
|
||||
{
|
||||
...{
|
||||
...widget,
|
||||
@@ -202,8 +202,8 @@ function WidgetGraphComponent({
|
||||
if (setLayouts) {
|
||||
setLayouts(updatedDashboard.data?.data?.layout || []);
|
||||
}
|
||||
if (setSelectedDashboard && updatedDashboard.data) {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
if (setDashboardData && updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
}
|
||||
notifications.success({
|
||||
message: 'Panel cloned successfully, redirecting to new copy.',
|
||||
|
||||
@@ -70,16 +70,16 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
useIsFetching([REACT_QUERY_KEY.DASHBOARD_BY_ID]) > 0;
|
||||
|
||||
const {
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
layouts,
|
||||
setLayouts,
|
||||
panelMap,
|
||||
setPanelMap,
|
||||
setSelectedDashboard,
|
||||
setDashboardData,
|
||||
columnWidths,
|
||||
} = useDashboardStore();
|
||||
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
|
||||
const { data } = selectedDashboard || {};
|
||||
const { data } = dashboardData || {};
|
||||
const { pathname } = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -124,7 +124,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
}
|
||||
|
||||
const userRole: ROLES | null =
|
||||
selectedDashboard?.createdBy === user?.email
|
||||
dashboardData?.createdBy === user?.email
|
||||
? (USER_ROLES.AUTHOR as ROLES)
|
||||
: user.role;
|
||||
|
||||
@@ -146,27 +146,27 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current && !isUndefined(data)) {
|
||||
logEvent('Dashboard Detail: Opened', {
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardId: dashboardData?.id,
|
||||
dashboardName: data.title,
|
||||
numberOfPanels: data.widgets?.length,
|
||||
numberOfVariables: Object.keys(dashboardVariables).length || 0,
|
||||
});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
}, [dashboardVariables, data, selectedDashboard?.id]);
|
||||
}, [dashboardVariables, data, dashboardData?.id]);
|
||||
|
||||
const onSaveHandler = (): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedDashboard: Props = {
|
||||
id: selectedDashboard.id,
|
||||
id: dashboardData.id,
|
||||
data: {
|
||||
...selectedDashboard.data,
|
||||
...dashboardData.data,
|
||||
panelMap: { ...currentPanelMap },
|
||||
layout: dashboardLayout.filter((e) => e.i !== PANEL_TYPES.EMPTY_WIDGET),
|
||||
widgets: selectedDashboard?.data?.widgets?.map((widget) => {
|
||||
widgets: dashboardData?.data?.widgets?.map((widget) => {
|
||||
if (columnWidths?.[widget.id]) {
|
||||
return {
|
||||
...widget,
|
||||
@@ -184,7 +184,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
if (updatedDashboard.data.data.layout) {
|
||||
setLayouts(sortLayout(updatedDashboard.data.data.layout));
|
||||
}
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
setDashboardData(updatedDashboard.data);
|
||||
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
|
||||
}
|
||||
},
|
||||
@@ -243,7 +243,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
dashboardLayout &&
|
||||
Array.isArray(dashboardLayout) &&
|
||||
dashboardLayout.length > 0 &&
|
||||
hasColumnWidthsChanged(columnWidths, selectedDashboard);
|
||||
hasColumnWidthsChanged(columnWidths, dashboardData);
|
||||
|
||||
if (shouldSaveLayout || shouldSaveColumnWidths) {
|
||||
onSaveHandler();
|
||||
@@ -253,7 +253,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
|
||||
const onSettingsModalSubmit = (): void => {
|
||||
const newTitle = form.getFieldValue('title');
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentWidget = selectedDashboard?.data?.widgets?.find(
|
||||
const currentWidget = dashboardData?.data?.widgets?.find(
|
||||
(e) => e.id === currentSelectRowId,
|
||||
);
|
||||
|
||||
@@ -269,25 +269,25 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedWidgets = selectedDashboard?.data?.widgets?.map((e) =>
|
||||
const updatedWidgets = dashboardData?.data?.widgets?.map((e) =>
|
||||
e.id === currentSelectRowId ? { ...e, title: newTitle } : e,
|
||||
);
|
||||
|
||||
const updatedSelectedDashboard: Props = {
|
||||
id: selectedDashboard.id,
|
||||
const updatedDashboardData: Props = {
|
||||
id: dashboardData.id,
|
||||
data: {
|
||||
...selectedDashboard.data,
|
||||
...dashboardData.data,
|
||||
widgets: updatedWidgets,
|
||||
},
|
||||
};
|
||||
|
||||
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
|
||||
updateDashboardMutation.mutateAsync(updatedDashboardData, {
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (setLayouts) {
|
||||
setLayouts(updatedDashboard.data?.data?.layout || []);
|
||||
}
|
||||
if (setSelectedDashboard && updatedDashboard.data) {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
if (setDashboardData && updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
}
|
||||
if (setPanelMap) {
|
||||
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
|
||||
@@ -311,7 +311,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
}, [currentSelectRowId, form, widgets]);
|
||||
|
||||
const handleRowCollapse = (id: string): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
|
||||
@@ -343,7 +343,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
};
|
||||
|
||||
const handleRowDelete = (): void => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -351,34 +351,33 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
|
||||
const updatedWidgets = dashboardData?.data?.widgets?.filter(
|
||||
(e) => e.id !== currentSelectRowId,
|
||||
);
|
||||
|
||||
const updatedLayout =
|
||||
selectedDashboard.data.layout?.filter((e) => e.i !== currentSelectRowId) ||
|
||||
[];
|
||||
dashboardData.data.layout?.filter((e) => e.i !== currentSelectRowId) || [];
|
||||
|
||||
const updatedPanelMap = { ...currentPanelMap };
|
||||
delete updatedPanelMap[currentSelectRowId];
|
||||
|
||||
const updatedSelectedDashboard: Props = {
|
||||
id: selectedDashboard.id,
|
||||
const updatedDashboardData: Props = {
|
||||
id: dashboardData.id,
|
||||
data: {
|
||||
...selectedDashboard.data,
|
||||
...dashboardData.data,
|
||||
widgets: updatedWidgets,
|
||||
layout: updatedLayout,
|
||||
panelMap: updatedPanelMap,
|
||||
},
|
||||
};
|
||||
|
||||
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
|
||||
updateDashboardMutation.mutateAsync(updatedDashboardData, {
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (setLayouts) {
|
||||
setLayouts(updatedDashboard.data?.data?.layout || []);
|
||||
}
|
||||
if (setSelectedDashboard && updatedDashboard.data) {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
if (setDashboardData && updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
}
|
||||
if (setPanelMap) {
|
||||
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
|
||||
@@ -390,10 +389,8 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
};
|
||||
const isDashboardEmpty = useMemo(
|
||||
() =>
|
||||
selectedDashboard?.data.layout
|
||||
? selectedDashboard?.data.layout?.length === 0
|
||||
: true,
|
||||
[selectedDashboard],
|
||||
dashboardData?.data.layout ? dashboardData?.data.layout?.length === 0 : true,
|
||||
[dashboardData],
|
||||
);
|
||||
|
||||
let isDataAvailableInAnyWidget = false;
|
||||
@@ -512,7 +509,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
widget={(currentWidget as Widgets) || ({ id, query: {} } as Widgets)}
|
||||
headerMenuList={widgetActions}
|
||||
variables={dashboardVariables}
|
||||
// version={selectedDashboard?.data?.version}
|
||||
// version={dashboardData?.data?.version}
|
||||
version={ENTITY_VERSION_V5}
|
||||
onDragSelect={onDragSelect}
|
||||
dataAvailable={checkIfDataExists}
|
||||
|
||||
@@ -42,14 +42,14 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
|
||||
const { selectedDashboard } = useDashboardStore();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
|
||||
|
||||
const permissions: ComponentTypes[] = ['add_panel'];
|
||||
const { user } = useAppContext();
|
||||
|
||||
const userRole: ROLES | null =
|
||||
selectedDashboard?.createdBy === user?.email
|
||||
dashboardData?.createdBy === user?.email
|
||||
? (USER_ROLES.AUTHOR as ROLES)
|
||||
: user.role;
|
||||
const [addPanelPermission] = useComponentPermission(permissions, userRole);
|
||||
@@ -87,11 +87,11 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => {
|
||||
// TODO: @AshwinBhatkal Simplify this check in cleanup of https://github.com/SigNoz/engineering-pod/issues/3953
|
||||
if (!selectedDashboard?.id) {
|
||||
if (!dashboardData?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedRowWidgetId(selectedDashboard.id, id);
|
||||
setSelectedRowWidgetId(dashboardData.id, id);
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -121,7 +121,7 @@ function useNavigateToExplorerPages(): (
|
||||
) => Promise<{
|
||||
[queryName: string]: { filters: TagFilterItem[]; dataSource?: string };
|
||||
}> {
|
||||
const { selectedDashboard } = useDashboardStore();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
return useCallback(
|
||||
@@ -143,7 +143,7 @@ function useNavigateToExplorerPages(): (
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedDashboard, notifications],
|
||||
[dashboardData, notifications],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ interface UseUpdatedQueryOptions {
|
||||
panelTypes: PANEL_TYPES;
|
||||
timePreferance: timePreferenceType;
|
||||
};
|
||||
selectedDashboard?: any;
|
||||
dashboardData?: any;
|
||||
}
|
||||
|
||||
interface UseUpdatedQueryResult {
|
||||
@@ -44,7 +44,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
const getUpdatedQuery = useCallback(
|
||||
async ({
|
||||
widgetConfig,
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
}: UseUpdatedQueryOptions): Promise<Query> => {
|
||||
// Prepare query payload with resolved variables
|
||||
const { queryPayload } = prepareQueryRangePayloadV5({
|
||||
@@ -52,7 +52,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
graphType: getGraphType(widgetConfig.panelTypes),
|
||||
selectedTime: widgetConfig.timePreferance,
|
||||
globalSelectedInterval,
|
||||
variables: getDashboardVariables(selectedDashboard?.data?.variables),
|
||||
variables: getDashboardVariables(dashboardData?.data?.variables),
|
||||
originalGraphType: widgetConfig.panelTypes,
|
||||
dynamicVariables: dashboardDynamicVariables,
|
||||
});
|
||||
|
||||
@@ -149,16 +149,16 @@ export function extractQueryNamesFromExpression(expression: string): string[] {
|
||||
|
||||
export const hasColumnWidthsChanged = (
|
||||
columnWidths: Record<string, Record<string, number>>,
|
||||
selectedDashboard?: Dashboard,
|
||||
dashboardData?: Dashboard,
|
||||
): boolean => {
|
||||
// If no column widths stored, no changes
|
||||
if (isEmpty(columnWidths) || !selectedDashboard) {
|
||||
if (isEmpty(columnWidths) || !dashboardData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each widget's column widths
|
||||
return Object.keys(columnWidths).some((widgetId) => {
|
||||
const dashboardWidget = selectedDashboard?.data?.widgets?.find(
|
||||
const dashboardWidget = dashboardData?.data?.widgets?.find(
|
||||
(widget) => widget.id === widgetId,
|
||||
) as Widgets;
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ jest.mock('utils/navigation', () => ({
|
||||
|
||||
const openInNewTabMock = openInNewTab as jest.Mock;
|
||||
|
||||
// Mock Date.now to prevent flaky tests due to time-dependent values
|
||||
const MOCK_NOW = 1700000000000; // Fixed timestamp
|
||||
jest.spyOn(Date, 'now').mockReturnValue(MOCK_NOW);
|
||||
|
||||
// Mock DrawerWrapper to avoid CSS issues with jsdom
|
||||
// SyntaxError: 'div#radix-:rbv,,._dialog__content_qf8bf_22 :focus' is not a valid selector
|
||||
jest.mock('@signozhq/ui', () => ({
|
||||
@@ -188,6 +192,7 @@ describe('K8sBaseList', () => {
|
||||
await waitFor(() => {
|
||||
const selectedItem = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('selectedItem'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(selectedItem).toBe(`PodId:${itemId}`);
|
||||
});
|
||||
|
||||
@@ -11,12 +11,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.infra-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.infra-metrics-card {
|
||||
margin: 1rem 0;
|
||||
height: 300px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQueries, UseQueryResult } from 'react-query';
|
||||
import { Card, Skeleton, Typography } from 'antd';
|
||||
import { Card, Col, Row, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
@@ -163,16 +163,16 @@ function NodeMetrics({
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="infra-metrics-grid">
|
||||
<Row gutter={24}>
|
||||
{queries.map((query, idx) => (
|
||||
<div key={widgetInfo[idx].title}>
|
||||
<Col span={12} key={widgetInfo[idx].title}>
|
||||
<Typography.Text>{widgetInfo[idx].title}</Typography.Text>
|
||||
<Card bordered className="infra-metrics-card" ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQueries, UseQueryResult } from 'react-query';
|
||||
import { Card, Skeleton, Typography } from 'antd';
|
||||
import { Card, Col, Row, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
@@ -146,16 +146,16 @@ function PodMetrics({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="infra-metrics-grid">
|
||||
<Row gutter={24}>
|
||||
{queries.map((query, idx) => (
|
||||
<div key={podWidgetInfo[idx].title}>
|
||||
<Col span={12} key={podWidgetInfo[idx].title}>
|
||||
<Typography.Text>{podWidgetInfo[idx].title}</Typography.Text>
|
||||
<Card bordered className="infra-metrics-card" ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -93,8 +93,8 @@ jest.mock('hooks/useDarkMode', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (): { selectedDashboard: undefined } => ({
|
||||
selectedDashboard: undefined,
|
||||
useDashboardStore: (): { dashboardData: undefined } => ({
|
||||
dashboardData: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('LogsPanelComponent', () => {
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
dashboardData={undefined}
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
|
||||
@@ -30,7 +30,7 @@ function LeftContainer({
|
||||
setRequestData,
|
||||
setQueryResponse,
|
||||
enableDrillDown = false,
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
isNewPanel = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
@@ -79,8 +79,8 @@ function LeftContainer({
|
||||
isLoadingQueries={queryResponse.isFetching}
|
||||
selectedWidget={selectedWidget}
|
||||
dashboardVersion={ENTITY_VERSION_V5}
|
||||
dashboardId={selectedDashboard?.id}
|
||||
dashboardName={selectedDashboard?.data.title}
|
||||
dashboardId={dashboardData?.id}
|
||||
dashboardName={dashboardData?.data.title}
|
||||
isNewPanel={isNewPanel}
|
||||
/>
|
||||
{selectedGraph === PANEL_TYPES.LIST && (
|
||||
|
||||
@@ -326,7 +326,7 @@ describe('Stacking bar in new panel', () => {
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
dashboardData={undefined}
|
||||
selectedGraph={PANEL_TYPES.BAR}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
@@ -378,7 +378,7 @@ describe('when switching to BAR panel type', () => {
|
||||
<DashboardBootstrapWrapper dashboardId="">
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
dashboardData={undefined}
|
||||
selectedGraph={PANEL_TYPES.BAR}
|
||||
/>
|
||||
</DashboardBootstrapWrapper>,
|
||||
|
||||
@@ -86,7 +86,7 @@ import {
|
||||
import './NewWidget.styles.scss';
|
||||
|
||||
function NewWidget({
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
dashboardId,
|
||||
selectedGraph,
|
||||
enableDrillDown = false,
|
||||
@@ -135,7 +135,7 @@ function NewWidget({
|
||||
[selectedGraph, globalSelectedInterval, isLogsQuery],
|
||||
);
|
||||
|
||||
const { widgets = [] } = selectedDashboard?.data || {};
|
||||
const { widgets = [] } = dashboardData?.data || {};
|
||||
|
||||
const query = useUrlQuery();
|
||||
|
||||
@@ -154,9 +154,9 @@ function NewWidget({
|
||||
if (!logEventCalledRef.current) {
|
||||
logEvent('Panel Edit: Page visited', {
|
||||
panelType: selectedWidget?.panelTypes,
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardId: dashboardData?.id,
|
||||
widgetId: selectedWidget?.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
dashboardName: dashboardData?.data.title,
|
||||
isNewPanel: !!isWidgetNotPresent,
|
||||
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
|
||||
});
|
||||
@@ -362,7 +362,7 @@ function NewWidget({
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const { afterWidgets, preWidgets } = useMemo(() => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return {
|
||||
selectedWidget: {} as Widgets,
|
||||
preWidgets: [],
|
||||
@@ -372,21 +372,18 @@ function NewWidget({
|
||||
|
||||
const widgetId = query.get('widgetId');
|
||||
|
||||
const selectedWidgetIndex = getSelectedWidgetIndex(
|
||||
selectedDashboard,
|
||||
widgetId,
|
||||
);
|
||||
const selectedWidgetIndex = getSelectedWidgetIndex(dashboardData, widgetId);
|
||||
|
||||
const preWidgets = getPreviousWidgets(selectedDashboard, selectedWidgetIndex);
|
||||
const preWidgets = getPreviousWidgets(dashboardData, selectedWidgetIndex);
|
||||
|
||||
const afterWidgets = getNextWidgets(selectedDashboard, selectedWidgetIndex);
|
||||
const afterWidgets = getNextWidgets(dashboardData, selectedWidgetIndex);
|
||||
|
||||
const selectedWidget = (selectedDashboard.data.widgets || [])[
|
||||
const selectedWidget = (dashboardData.data.widgets || [])[
|
||||
selectedWidgetIndex || 0
|
||||
];
|
||||
|
||||
return { selectedWidget, preWidgets, afterWidgets };
|
||||
}, [selectedDashboard, query]);
|
||||
}, [dashboardData, query]);
|
||||
|
||||
// this loading state is to take care of mismatch in the responses for table and other panels
|
||||
// hence while changing the query contains the older value and the processing logic fails
|
||||
@@ -483,12 +480,12 @@ function NewWidget({
|
||||
}, [dashboardId, query, safeNavigate]);
|
||||
|
||||
const onClickSaveHandler = useCallback(() => {
|
||||
if (!selectedDashboard) {
|
||||
if (!dashboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const widgetId = query.get('widgetId') || '';
|
||||
let updatedLayout = selectedDashboard.data.layout || [];
|
||||
let updatedLayout = dashboardData.data.layout || [];
|
||||
|
||||
const selectedRowWidgetId = getSelectedRowWidgetId(dashboardId);
|
||||
|
||||
@@ -522,10 +519,10 @@ function NewWidget({
|
||||
const adjustedQueryForV5 = adjustQueryForV5(currentQuery);
|
||||
|
||||
const dashboard: Props = {
|
||||
id: selectedDashboard.id,
|
||||
id: dashboardData.id,
|
||||
|
||||
data: {
|
||||
...selectedDashboard.data,
|
||||
...dashboardData.data,
|
||||
widgets: isNewDashboard
|
||||
? [
|
||||
...afterWidgets,
|
||||
@@ -603,7 +600,7 @@ function NewWidget({
|
||||
},
|
||||
});
|
||||
}, [
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
query,
|
||||
isNewDashboard,
|
||||
afterWidgets,
|
||||
@@ -672,9 +669,9 @@ function NewWidget({
|
||||
const onSaveDashboard = useCallback((): void => {
|
||||
logEvent('Panel Edit: Save changes', {
|
||||
panelType: selectedWidget.panelTypes,
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardId: dashboardData?.id,
|
||||
widgetId: selectedWidget.id,
|
||||
dashboardName: selectedDashboard?.data.title,
|
||||
dashboardName: dashboardData?.data.title,
|
||||
queryType: currentQuery.queryType,
|
||||
isNewPanel,
|
||||
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
|
||||
@@ -870,7 +867,7 @@ function NewWidget({
|
||||
<OverlayScrollbar>
|
||||
{selectedWidget && (
|
||||
<LeftContainer
|
||||
selectedDashboard={selectedDashboard}
|
||||
dashboardData={dashboardData}
|
||||
selectedGraph={graphType}
|
||||
selectedLogFields={selectedLogFields}
|
||||
setSelectedLogFields={setSelectedLogFields}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { timePreferance } from './RightContainer/timeItems';
|
||||
|
||||
export interface NewWidgetProps {
|
||||
dashboardId: string;
|
||||
selectedDashboard: Dashboard | undefined;
|
||||
dashboardData: Dashboard | undefined;
|
||||
selectedGraph: PANEL_TYPES;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export interface WidgetGraphProps {
|
||||
>
|
||||
>;
|
||||
enableDrillDown?: boolean;
|
||||
selectedDashboard: Dashboard | undefined;
|
||||
dashboardData: Dashboard | undefined;
|
||||
isNewPanel?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ const useBaseAggregateOptions = ({
|
||||
getUpdatedQuery,
|
||||
isLoading: isResolveQueryLoading,
|
||||
} = useUpdatedQuery();
|
||||
const { selectedDashboard } = useDashboardStore();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!aggregateData) {
|
||||
@@ -79,7 +79,7 @@ const useBaseAggregateOptions = ({
|
||||
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
},
|
||||
selectedDashboard,
|
||||
dashboardData,
|
||||
});
|
||||
setResolvedQuery(updatedQuery);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ jest.mock('react-router-dom', () => ({
|
||||
// Mock useDashabord hook
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (): any => ({
|
||||
selectedDashboard: {
|
||||
dashboardData: {
|
||||
data: {
|
||||
variables: [],
|
||||
},
|
||||
|
||||
@@ -46,7 +46,6 @@ export const routeConfig: Record<string, QueryParams[]> = {
|
||||
[ROUTES.TRACES_EXPLORER]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.TRACE]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.TRACE_DETAIL]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.TRACE_DETAIL_OLD]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.UN_AUTHORIZED]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.USAGE_EXPLORER]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.VERSION]: [QueryParams.resourceAttributes],
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
&-empty-content {
|
||||
height: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -143,7 +143,6 @@ export const routesToSkip = [
|
||||
ROUTES.SETTINGS,
|
||||
ROUTES.LIST_ALL_ALERT,
|
||||
ROUTES.TRACE_DETAIL,
|
||||
ROUTES.TRACE_DETAIL_OLD,
|
||||
ROUTES.ALL_CHANNELS,
|
||||
ROUTES.USAGE_EXPLORER,
|
||||
ROUTES.GET_STARTED,
|
||||
|
||||
@@ -48,7 +48,7 @@ export function useDashboardBootstrap(
|
||||
);
|
||||
|
||||
const {
|
||||
setSelectedDashboard,
|
||||
setDashboardData,
|
||||
setLayouts,
|
||||
setPanelMap,
|
||||
resetDashboardStore,
|
||||
@@ -65,7 +65,7 @@ export function useDashboardBootstrap(
|
||||
transformDashboardVariables,
|
||||
} = useTransformDashboardVariables(dashboardId);
|
||||
|
||||
// Keep the external variables store in sync with selectedDashboard
|
||||
// Keep the external variables store in sync with dashboardData
|
||||
useDashboardVariablesSync(dashboardId);
|
||||
|
||||
const dashboardQuery = useDashboardQuery(dashboardId);
|
||||
@@ -88,7 +88,7 @@ export function useDashboardBootstrap(
|
||||
if (variables) {
|
||||
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
|
||||
}
|
||||
setSelectedDashboard(updatedDashboardData);
|
||||
setDashboardData(updatedDashboardData);
|
||||
dashboardRef.current = updatedDashboardData;
|
||||
setLayouts(sortLayout(getUpdatedLayout(updatedDashboardData?.data.layout)));
|
||||
setPanelMap(defaultTo(updatedDashboardData?.data?.panelMap, {}));
|
||||
@@ -107,7 +107,7 @@ export function useDashboardBootstrap(
|
||||
title: t('dashboard_has_been_updated'),
|
||||
content: t('do_you_want_to_refresh_the_dashboard'),
|
||||
onOk() {
|
||||
setSelectedDashboard(updatedDashboardData);
|
||||
setDashboardData(updatedDashboardData);
|
||||
|
||||
const { maxTime, minTime } = getMinMaxForSelectedTime(
|
||||
globalTime.selectedTime,
|
||||
|
||||
@@ -13,21 +13,21 @@ import { useDashboardVariablesSelector } from './useDashboardVariables';
|
||||
|
||||
/**
|
||||
* Keeps the external variables store in sync with the zustand dashboard store.
|
||||
* When selectedDashboard changes, propagates variable updates to the variables store.
|
||||
* When dashboardData changes, propagates variable updates to the variables store.
|
||||
*/
|
||||
export function useDashboardVariablesSync(dashboardId: string): void {
|
||||
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
|
||||
const savedDashboardId = useDashboardVariablesSelector((s) => s.dashboardId);
|
||||
const selectedDashboard = useDashboardStore(
|
||||
(s: DashboardStore) => s.selectedDashboard,
|
||||
const dashboardData = useDashboardStore(
|
||||
(s: DashboardStore) => s.dashboardData,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updatedVariables = selectedDashboard?.data.variables || {};
|
||||
const updatedVariables = dashboardData?.data.variables || {};
|
||||
if (savedDashboardId !== dashboardId) {
|
||||
setDashboardVariablesStore({ dashboardId, variables: updatedVariables });
|
||||
} else if (!isEqual(dashboardVariables, updatedVariables)) {
|
||||
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
|
||||
}
|
||||
}, [selectedDashboard]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [dashboardData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation } from 'react-query';
|
||||
import locked from 'api/v1/dashboards/id/lock';
|
||||
import {
|
||||
getSelectedDashboard,
|
||||
getDashboardData,
|
||||
useDashboardStore,
|
||||
} from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
@@ -13,13 +13,11 @@ import APIError from 'types/api/error';
|
||||
*/
|
||||
export function useLockDashboard(): (value: boolean) => Promise<void> {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { setSelectedDashboard } = useDashboardStore();
|
||||
const { setDashboardData } = useDashboardStore();
|
||||
|
||||
const { mutate: lockDashboard } = useMutation(locked, {
|
||||
onSuccess: (_, props) => {
|
||||
setSelectedDashboard((prev) =>
|
||||
prev ? { ...prev, locked: props.lock } : prev,
|
||||
);
|
||||
setDashboardData((prev) => (prev ? { ...prev, locked: props.lock } : prev));
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorModal(error as APIError);
|
||||
@@ -27,11 +25,11 @@ export function useLockDashboard(): (value: boolean) => Promise<void> {
|
||||
});
|
||||
|
||||
return async (value: boolean): Promise<void> => {
|
||||
const selectedDashboard = getSelectedDashboard();
|
||||
if (selectedDashboard) {
|
||||
const dashboardData = getDashboardData();
|
||||
if (dashboardData) {
|
||||
try {
|
||||
await lockDashboard({
|
||||
id: selectedDashboard.id,
|
||||
id: dashboardData.id,
|
||||
lock: value,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,11 +12,11 @@ import { useDashboardVariablesByType } from './useDashboardVariablesByType';
|
||||
*/
|
||||
export function useWidgetsByDynamicVariableId(): Record<string, string[]> {
|
||||
const dynamicVariables = useDashboardVariablesByType('DYNAMIC', 'values');
|
||||
const { selectedDashboard } = useDashboardStore();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
|
||||
return useMemo(() => {
|
||||
const widgets =
|
||||
selectedDashboard?.data?.widgets?.filter(
|
||||
dashboardData?.data?.widgets?.filter(
|
||||
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
|
||||
) || [];
|
||||
|
||||
@@ -24,5 +24,5 @@ export function useWidgetsByDynamicVariableId(): Record<string, string[]> {
|
||||
dynamicVariables,
|
||||
widgets as Widgets[],
|
||||
);
|
||||
}, [selectedDashboard, dynamicVariables]);
|
||||
}, [dashboardData, dynamicVariables]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useMutation } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import useCreateAlerts from '../useCreateAlerts';
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
useMutation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/dashboard/substitute_vars', () => ({
|
||||
getSubstituteVars: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/v5/v5', () => ({
|
||||
prepareQueryRangePayloadV5: jest.fn().mockReturnValue({ queryPayload: {} }),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
|
||||
() => ({
|
||||
mapQueryDataFromApi: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
|
||||
useDashboardVariables: (): unknown => ({ dashboardVariables: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
|
||||
useDashboardVariablesByType: (): unknown => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): unknown => ({
|
||||
notifications: { error: jest.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/dashboardVariables/getDashboardVariables', () => ({
|
||||
getDashboardVariables: (): unknown => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (): unknown => ({ dashboardData: undefined }),
|
||||
}));
|
||||
|
||||
jest.mock('utils/getGraphType', () => ({
|
||||
getGraphType: jest.fn().mockReturnValue('time_series'),
|
||||
}));
|
||||
|
||||
const mockMapQueryDataFromApi = mapQueryDataFromApi as jest.MockedFunction<
|
||||
typeof mapQueryDataFromApi
|
||||
>;
|
||||
const mockUseMutation = useMutation as jest.MockedFunction<typeof useMutation>;
|
||||
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;
|
||||
|
||||
const buildWidget = (queryType: EQueryType | undefined): Widgets =>
|
||||
(({
|
||||
id: 'widget-1',
|
||||
panelTypes: 'graph',
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
yAxisUnit: '',
|
||||
query: {
|
||||
queryType,
|
||||
builder: { queryData: [], queryFormulas: [] },
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
id: 'q-1',
|
||||
},
|
||||
} as unknown) as Widgets);
|
||||
|
||||
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
|
||||
const [url] = (window.open as jest.Mock).mock.calls[0];
|
||||
const query = new URLSearchParams((url as string).split('?')[1]);
|
||||
const raw = query.get(QueryParams.compositeQuery);
|
||||
if (!raw) {
|
||||
throw new Error('compositeQuery not found in URL');
|
||||
}
|
||||
return JSON.parse(decodeURIComponent(raw));
|
||||
};
|
||||
|
||||
describe('useCreateAlerts', () => {
|
||||
let capturedOnSuccess:
|
||||
| ((data: { data: { compositeQuery: unknown } }) => void)
|
||||
| null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
capturedOnSuccess = null;
|
||||
|
||||
mockUseSelector.mockReturnValue({ selectedTime: '1h' });
|
||||
|
||||
mockUseMutation.mockReturnValue(({
|
||||
mutate: jest.fn((_payload, opts) => {
|
||||
capturedOnSuccess = opts?.onSuccess ?? null;
|
||||
}),
|
||||
} as unknown) as ReturnType<typeof useMutation>);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).open = jest.fn();
|
||||
});
|
||||
|
||||
it('preserves widget queryType when the API response maps to a different queryType', () => {
|
||||
mockMapQueryDataFromApi.mockReturnValue({
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [], queryFormulas: [] },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const widget = buildWidget(EQueryType.CLICKHOUSE);
|
||||
const { result } = renderHook(() => useCreateAlerts(widget));
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(capturedOnSuccess).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
capturedOnSuccess?.({ data: { compositeQuery: {} } });
|
||||
});
|
||||
|
||||
const composite = getCompositeQueryFromLastOpen();
|
||||
expect(composite.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
});
|
||||
|
||||
it('preserves promql queryType through the alert navigation URL', () => {
|
||||
mockMapQueryDataFromApi.mockReturnValue({
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [], queryFormulas: [] },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const widget = buildWidget(EQueryType.PROM);
|
||||
const { result } = renderHook(() => useCreateAlerts(widget));
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
act(() => {
|
||||
capturedOnSuccess?.({ data: { compositeQuery: {} } });
|
||||
});
|
||||
|
||||
const composite = getCompositeQueryFromLastOpen();
|
||||
expect(composite.queryType).toBe(EQueryType.PROM);
|
||||
});
|
||||
|
||||
it('falls back to the mapped queryType when widget has no queryType', () => {
|
||||
mockMapQueryDataFromApi.mockReturnValue({
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [], queryFormulas: [] },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const widget = buildWidget(undefined);
|
||||
const { result } = renderHook(() => useCreateAlerts(widget));
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
act(() => {
|
||||
capturedOnSuccess?.({ data: { compositeQuery: {} } });
|
||||
});
|
||||
|
||||
const composite = getCompositeQueryFromLastOpen();
|
||||
// No override, so the mapped value wins.
|
||||
expect(composite.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
});
|
||||
|
||||
it('does nothing when widget is undefined', () => {
|
||||
const { result } = renderHook(() => useCreateAlerts(undefined));
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
const mutateCalls = (mockUseMutation.mock.results[0].value as ReturnType<
|
||||
typeof useMutation
|
||||
>).mutate as jest.Mock;
|
||||
expect(mutateCalls).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { selectedDashboard } = useDashboardStore();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const dashboardDynamicVariables = useDashboardVariablesByType(
|
||||
@@ -49,8 +49,8 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
if (caller === 'panelView') {
|
||||
logEvent('Panel Edit: Create alert', {
|
||||
panelType: widget.panelTypes,
|
||||
dashboardName: selectedDashboard?.data?.title,
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardName: dashboardData?.data?.title,
|
||||
dashboardId: dashboardData?.id,
|
||||
widgetId: widget.id,
|
||||
queryType: widget.query.queryType,
|
||||
});
|
||||
@@ -58,8 +58,8 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
logEvent('Dashboard Detail: Panel action', {
|
||||
action: MenuItemKeys.CreateAlerts,
|
||||
panelType: widget.panelTypes,
|
||||
dashboardName: selectedDashboard?.data?.title,
|
||||
dashboardId: selectedDashboard?.id,
|
||||
dashboardName: dashboardData?.data?.title,
|
||||
dashboardId: dashboardData?.id,
|
||||
widgetId: widget.id,
|
||||
queryType: widget.query.queryType,
|
||||
});
|
||||
@@ -76,6 +76,9 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
queryRangeMutation.mutate(queryPayload, {
|
||||
onSuccess: (data) => {
|
||||
const updatedQuery = mapQueryDataFromApi(data.data.compositeQuery);
|
||||
if (widget.query.queryType) {
|
||||
updatedQuery.queryType = widget.query.queryType;
|
||||
}
|
||||
// If widget has a y-axis unit, set it to the updated query if it is not already set
|
||||
if (widget.yAxisUnit && !isEmpty(widget.yAxisUnit)) {
|
||||
updatedQuery.unit = widget.yAxisUnit;
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { MouseEventHandler, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
// Accepts both V2 (spanId) and V3 (span_id) span shapes
|
||||
// TODO: Remove V2 (spanId) support when phasing out V2
|
||||
interface SpanLike {
|
||||
spanId?: string;
|
||||
span_id?: string;
|
||||
}
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
export const useCopySpanLink = (
|
||||
span?: SpanLike,
|
||||
span?: Span,
|
||||
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useLocation();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const onSpanCopy: MouseEventHandler<HTMLElement> = useCallback(
|
||||
(event) => {
|
||||
@@ -29,20 +24,18 @@ export const useCopySpanLink = (
|
||||
|
||||
urlQuery.delete('spanId');
|
||||
|
||||
const id = span.span_id || span.spanId;
|
||||
if (id) {
|
||||
urlQuery.set('spanId', id);
|
||||
if (span.spanId) {
|
||||
urlQuery.set('spanId', span?.spanId);
|
||||
}
|
||||
|
||||
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
|
||||
|
||||
setCopy(link);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
notifications.success({
|
||||
message: 'Copied to clipboard',
|
||||
});
|
||||
},
|
||||
[span, urlQuery, pathname, setCopy],
|
||||
[span, urlQuery, pathname, setCopy, notifications],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,7 +16,7 @@ const useGetTraceFlamegraph = (
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
|
||||
props.traceId,
|
||||
// props.selectedSpanId,
|
||||
props.selectedSpanId,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import getTraceV3 from 'api/trace/getTraceV3';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV3PayloadProps,
|
||||
GetTraceV3SuccessResponse,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceV3(props),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V3_WATERFALL,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.isSelectedSpanIDUnCollapsed,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
type UseTraceV3 = UseQueryResult<
|
||||
SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export default useGetTraceV3;
|
||||
@@ -7,23 +7,6 @@ export function hashFn(str: string): number {
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function colorToRgb(color: string): string {
|
||||
// Handle hex colors
|
||||
const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
||||
if (hexMatch) {
|
||||
return `${parseInt(hexMatch[1], 16)}, ${parseInt(
|
||||
hexMatch[2],
|
||||
16,
|
||||
)}, ${parseInt(hexMatch[3], 16)}`;
|
||||
}
|
||||
// Handle rgb() colors
|
||||
const rgbMatch = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(color);
|
||||
if (rgbMatch) {
|
||||
return `${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}`;
|
||||
}
|
||||
return '136, 136, 136';
|
||||
}
|
||||
|
||||
export function generateColor(
|
||||
key: string,
|
||||
colorMap: Record<string, string>,
|
||||
|
||||
@@ -62,10 +62,10 @@ export interface TooltipRenderArgs {
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
showTooltipHeader?: boolean;
|
||||
timezone?: Timezone;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
content?: TooltipContentItem[];
|
||||
timezone?: Timezone;
|
||||
}
|
||||
|
||||
export interface TimeSeriesTooltipProps
|
||||
|
||||
@@ -4,6 +4,7 @@ import cx from 'classnames';
|
||||
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { syncCursorRegistry } from './syncCursorRegistry';
|
||||
import {
|
||||
createInitialControllerState,
|
||||
createSetCursorHandler,
|
||||
@@ -40,6 +41,7 @@ export default function TooltipPlugin({
|
||||
maxHeight = 600,
|
||||
syncMode = DashboardCursorSync.None,
|
||||
syncKey = '_tooltip_sync_global_',
|
||||
syncMetadata,
|
||||
pinnedTooltipElement,
|
||||
canPinTooltip = false,
|
||||
}: TooltipPluginProps): JSX.Element | null {
|
||||
@@ -100,7 +102,29 @@ export default function TooltipPlugin({
|
||||
// crosshair / tooltip can follow the dashboard-wide cursor.
|
||||
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
|
||||
config.setCursor({
|
||||
sync: { key: syncKey, scales: ['x', null] },
|
||||
sync: { key: syncKey, scales: ['x', 'y'] },
|
||||
});
|
||||
|
||||
// Show the horizontal crosshair only when the receiving panel shares
|
||||
// the same y-axis unit as the source panel. When this panel is the
|
||||
// source (cursor.event != null) the line is always shown and this
|
||||
// panel's metadata is written to the registry so receivers can read it.
|
||||
config.addHook('setCursor', (u: uPlot): void => {
|
||||
const yCursorEl = u.root.querySelector<HTMLElement>('.u-cursor-y');
|
||||
if (!yCursorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (u.cursor.event != null) {
|
||||
// This panel is the source — publish metadata and always show line.
|
||||
syncCursorRegistry.setMetadata(syncKey, syncMetadata);
|
||||
yCursorEl.style.display = '';
|
||||
} else {
|
||||
// This panel is receiving sync — show only if units match.
|
||||
const sourceMeta = syncCursorRegistry.getMetadata(syncKey);
|
||||
yCursorEl.style.display =
|
||||
sourceMeta?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { TooltipSyncMetadata } from './types';
|
||||
|
||||
/**
|
||||
* Module-level registry that tracks the metadata of the panel currently
|
||||
* acting as the cursor source (the one being hovered) per sync group.
|
||||
*
|
||||
* uPlot fires the source panel's setCursor hook before broadcasting to
|
||||
* receivers, so the registry is always populated before receivers read it.
|
||||
*
|
||||
* Receivers use this to make decisions such as:
|
||||
* - Whether to show the horizontal crosshair line (matching yAxisUnit)
|
||||
* - Future: what to render inside the tooltip (matching groupBy, etc.)
|
||||
*/
|
||||
const metadataBySyncKey = new Map<string, TooltipSyncMetadata | undefined>();
|
||||
|
||||
export const syncCursorRegistry = {
|
||||
setMetadata(syncKey: string, metadata: TooltipSyncMetadata | undefined): void {
|
||||
metadataBySyncKey.set(syncKey, metadata);
|
||||
},
|
||||
|
||||
getMetadata(syncKey: string): TooltipSyncMetadata | undefined {
|
||||
return metadataBySyncKey.get(syncKey);
|
||||
},
|
||||
};
|
||||
@@ -34,11 +34,16 @@ export interface TooltipLayoutInfo {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TooltipSyncMetadata {
|
||||
yAxisUnit?: string;
|
||||
}
|
||||
|
||||
export interface TooltipPluginProps {
|
||||
config: UPlotConfigBuilder;
|
||||
canPinTooltip?: boolean;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
syncMetadata?: TooltipSyncMetadata;
|
||||
render: (args: TooltipRenderArgs) => ReactNode;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
|
||||
maxWidth?: number;
|
||||
|
||||
@@ -516,7 +516,7 @@ describe('TooltipPlugin', () => {
|
||||
);
|
||||
|
||||
expect(setCursorSpy).toHaveBeenCalledWith({
|
||||
sync: { key: 'dashboard-sync', scales: ['x', null] },
|
||||
sync: { key: 'dashboard-sync', scales: ['x', 'y'] },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -569,12 +569,8 @@ describe('TooltipPlugin', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const resizeCall = addSpy.mock.calls.find(
|
||||
([type]) => type === ('resize' as keyof WindowEventMap),
|
||||
);
|
||||
const scrollCall = addSpy.mock.calls.find(
|
||||
([type]) => type === ('scroll' as keyof WindowEventMap),
|
||||
);
|
||||
const resizeCall = addSpy.mock.calls.find(([type]) => type === 'resize');
|
||||
const scrollCall = addSpy.mock.calls.find(([type]) => type === 'scroll');
|
||||
|
||||
expect(resizeCall).toBeDefined();
|
||||
expect(scrollCall).toBeDefined();
|
||||
|
||||
@@ -21,9 +21,7 @@ function DashboardPage(): JSX.Element {
|
||||
error,
|
||||
} = useDashboardBootstrap(dashboardId, { confirm: onModal.confirm });
|
||||
|
||||
const dashboardTitle = useDashboardStore(
|
||||
(s) => s.selectedDashboard?.data.title,
|
||||
);
|
||||
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = dashboardTitle || document.title;
|
||||
|
||||
@@ -62,9 +62,9 @@ function DashboardWidgetInternal({
|
||||
widgetId: string;
|
||||
graphType: PANEL_TYPES;
|
||||
}): JSX.Element | null {
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<
|
||||
Dashboard | undefined
|
||||
>(undefined);
|
||||
const [dashboardData, setDashboardData] = useState<Dashboard | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const { transformDashboardVariables } = useTransformDashboardVariables(
|
||||
dashboardId,
|
||||
@@ -83,7 +83,7 @@ function DashboardWidgetInternal({
|
||||
cacheTime: DASHBOARD_CACHE_TIME,
|
||||
onSuccess: (response) => {
|
||||
const updatedDashboardData = transformDashboardVariables(response.data);
|
||||
setSelectedDashboard(updatedDashboardData);
|
||||
setDashboardData(updatedDashboardData);
|
||||
setDashboardVariablesStore({
|
||||
dashboardId,
|
||||
variables: updatedDashboardData.data.variables,
|
||||
@@ -108,7 +108,7 @@ function DashboardWidgetInternal({
|
||||
dashboardId={dashboardId}
|
||||
selectedGraph={graphType}
|
||||
enableDrillDown={isDrilldownEnabled()}
|
||||
selectedDashboard={selectedDashboard}
|
||||
dashboardData={dashboardData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import TraceDetailsV3 from '../TraceDetailsV3';
|
||||
|
||||
export default function TraceDetailV3Page(): JSX.Element {
|
||||
return <TraceDetailsV3 />;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
.analytics-panel {
|
||||
&__body {
|
||||
padding: 12px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--l1-background);
|
||||
|
||||
// TabsRoot — last direct child div
|
||||
> div:last-child {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[role='tablist'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
gap: 4px 8px;
|
||||
padding: 8px 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&__service-name {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__bar-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--l3-background);
|
||||
border-radius: 3px;
|
||||
min-width: 40px;
|
||||
|
||||
&--small {
|
||||
max-width: 80px;
|
||||
flex: 0 0 80px;
|
||||
}
|
||||
}
|
||||
|
||||
&__bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&--wide {
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
&--narrow {
|
||||
min-width: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs root
|
||||
[class*='tabs__list-wrapper'] {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@signozhq/ui';
|
||||
import { DetailsHeader } from 'components/DetailsPanel';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
|
||||
import './AnalyticsPanel.styles.scss';
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
serviceExecTime?: Record<string, number>;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
// TODO: Re-enable when backend provides per-service span counts
|
||||
// spans?: Span[];
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 350;
|
||||
const PANEL_MARGIN_RIGHT = 100;
|
||||
const PANEL_MARGIN_TOP = 50;
|
||||
const PANEL_MARGIN_BOTTOM = 50;
|
||||
|
||||
function AnalyticsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
serviceExecTime = {},
|
||||
traceStartTime = 0,
|
||||
traceEndTime = 0,
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const spread = traceEndTime - traceStartTime;
|
||||
|
||||
const execTimeRows = useMemo(() => {
|
||||
if (spread <= 0) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(serviceExecTime)
|
||||
.map(([service, duration]) => ({
|
||||
service,
|
||||
percentage: (duration * 100) / spread,
|
||||
color: generateColor(service, themeColors.traceDetailColorsV3),
|
||||
}))
|
||||
.sort((a, b) => b.percentage - a.percentage);
|
||||
}, [serviceExecTime, spread]);
|
||||
|
||||
// const spanCountRows = useMemo(() => {
|
||||
// const counts: Record<string, number> = {};
|
||||
// for (const span of spans) {
|
||||
// const name = span.serviceName || 'unknown';
|
||||
// counts[name] = (counts[name] || 0) + 1;
|
||||
// }
|
||||
// return Object.entries(counts)
|
||||
// .map(([service, count]) => ({
|
||||
// service,
|
||||
// count,
|
||||
// color: generateColor(service, themeColors.traceDetailColorsV3),
|
||||
// }))
|
||||
// .sort((a, b) => b.count - a.count);
|
||||
// }, [spans]);
|
||||
|
||||
// const maxSpanCount = spanCountRows[0]?.count || 1;
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
isOpen
|
||||
className="analytics-panel"
|
||||
width={PANEL_WIDTH}
|
||||
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
|
||||
y: PANEL_MARGIN_TOP,
|
||||
}}
|
||||
enableResizing={{
|
||||
top: true,
|
||||
bottom: true,
|
||||
left: false,
|
||||
right: false,
|
||||
topLeft: false,
|
||||
topRight: false,
|
||||
bottomLeft: false,
|
||||
bottomRight: false,
|
||||
}}
|
||||
>
|
||||
<DetailsHeader
|
||||
title="Analytics"
|
||||
onClose={onClose}
|
||||
className="floating-panel__drag-handle"
|
||||
/>
|
||||
|
||||
<div className="analytics-panel__body">
|
||||
<TabsRoot defaultValue="exec-time">
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
% exec time
|
||||
</TabsTrigger>
|
||||
{/* TODO: Enable when backend provides per-service span counts
|
||||
<TabsTrigger value="spans" variant="secondary">
|
||||
Spans
|
||||
</TabsTrigger>
|
||||
*/}
|
||||
</TabsList>
|
||||
|
||||
<div className="analytics-panel__tabs-scroll">
|
||||
<TabsContent value="exec-time">
|
||||
<div className="analytics-panel__list">
|
||||
{execTimeRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.service}-dot`}
|
||||
className="analytics-panel__dot"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span
|
||||
key={`${row.service}-name`}
|
||||
className="analytics-panel__service-name"
|
||||
>
|
||||
{row.service}
|
||||
</span>
|
||||
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
|
||||
<div className="analytics-panel__bar">
|
||||
<div
|
||||
className="analytics-panel__bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(row.percentage, 100)}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="analytics-panel__value analytics-panel__value--wide">
|
||||
{row.percentage.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* TODO: Enable when backend provides per-service span counts
|
||||
<TabsContent value="spans">
|
||||
<div className="analytics-panel__list">
|
||||
{spanCountRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.service}-dot`}
|
||||
className="analytics-panel__dot"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span
|
||||
key={`${row.service}-name`}
|
||||
className="analytics-panel__service-name"
|
||||
>
|
||||
{row.service}
|
||||
</span>
|
||||
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
|
||||
<div className="analytics-panel__bar">
|
||||
<div
|
||||
className="analytics-panel__bar-fill"
|
||||
style={{
|
||||
width: `${(row.count / maxSpanCount) * 100}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="analytics-panel__value analytics-panel__value--narrow">
|
||||
{row.count}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
*/}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</FloatingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalyticsPanel;
|
||||
@@ -1,34 +0,0 @@
|
||||
.linked-spans {
|
||||
position: relative;
|
||||
|
||||
&__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
&__label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
|
||||
import './LinkedSpans.styles.scss';
|
||||
|
||||
interface SpanReference {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
refType: string;
|
||||
}
|
||||
|
||||
interface LinkedSpansProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
references: any;
|
||||
}
|
||||
|
||||
interface LinkedSpansState {
|
||||
linkedSpans: SpanReference[];
|
||||
count: number;
|
||||
isOpen: boolean;
|
||||
toggleOpen: () => void;
|
||||
}
|
||||
|
||||
export function useLinkedSpans(references: any): LinkedSpansState {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const linkedSpans: SpanReference[] = useMemo(
|
||||
() =>
|
||||
(references || []).filter(
|
||||
(ref: SpanReference) => ref.refType !== 'CHILD_OF',
|
||||
),
|
||||
[references],
|
||||
);
|
||||
|
||||
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
|
||||
|
||||
return {
|
||||
linkedSpans,
|
||||
count: linkedSpans.length,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
};
|
||||
}
|
||||
|
||||
export function LinkedSpansToggle({
|
||||
count,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
}: {
|
||||
count: number;
|
||||
isOpen: boolean;
|
||||
toggleOpen: () => void;
|
||||
}): JSX.Element {
|
||||
if (count === 0) {
|
||||
return <span className="linked-spans__label">0 linked spans</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="linked-spans__toggle" onClick={toggleOpen}>
|
||||
<span className="linked-spans__label">
|
||||
{count} linked span{count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkedSpansPanel({
|
||||
linkedSpans,
|
||||
isOpen,
|
||||
}: {
|
||||
linkedSpans: SpanReference[];
|
||||
isOpen: boolean;
|
||||
}): JSX.Element | null {
|
||||
const getLink = useCallback(
|
||||
(item: SpanReference): string =>
|
||||
`${ROUTES.TRACE}/${item.traceId}?spanId=${item.spanId}`,
|
||||
[],
|
||||
);
|
||||
|
||||
if (!isOpen || linkedSpans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="linked-spans__list">
|
||||
{linkedSpans.map((item) => (
|
||||
<KeyValueLabel
|
||||
key={item.spanId}
|
||||
badgeKey="Linked Span ID"
|
||||
badgeValue={
|
||||
<Link to={getLink(item)}>
|
||||
<Badge color="vanilla">{item.spanId}</Badge>
|
||||
</Link>
|
||||
}
|
||||
direction="column"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedSpans({ references }: LinkedSpansProps): JSX.Element {
|
||||
const { linkedSpans, count, isOpen, toggleOpen } = useLinkedSpans(references);
|
||||
|
||||
return (
|
||||
<div className="linked-spans">
|
||||
<LinkedSpansToggle count={count} isOpen={isOpen} toggleOpen={toggleOpen} />
|
||||
<LinkedSpansPanel linkedSpans={linkedSpans} isOpen={isOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkedSpans;
|
||||
@@ -1,147 +0,0 @@
|
||||
.span-details-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&__header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
background: var(--l1-background);
|
||||
font-size: 14px;
|
||||
gap: 16px;
|
||||
|
||||
.data-viewer {
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
&__details-section {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__tabs-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
// TabsRoot — direct child of tabs-section
|
||||
> div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[role='tablist'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[class*='tabs__list-wrapper'] {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
[role='tabpanel'] {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__span-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__span-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
&__span-info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__highlighted-options {
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
|
||||
.key-value-label {
|
||||
flex: 1 1 50%;
|
||||
min-width: 120px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&__service-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-forest-500);
|
||||
}
|
||||
|
||||
&__trace-id {
|
||||
color: var(--accent-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__key-attributes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
&-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l2-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.48px;
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
|
||||
&-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,607 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import {
|
||||
Bookmark,
|
||||
CalendarClock,
|
||||
ChartBar,
|
||||
ChartColumnBig,
|
||||
Dock,
|
||||
Link2,
|
||||
Logs,
|
||||
PanelBottom,
|
||||
ScrollText,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@signozhq/ui';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
|
||||
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
|
||||
import { DetailsPanelState } from 'components/DetailsPanel/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { getLeafKeyFromPath } from 'periscope/components/PrettyView/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import AnalyticsPanel from './AnalyticsPanel/AnalyticsPanel';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
// KEY_ATTRIBUTE_KEYS, // uncomment when key attributes section is re-enabled
|
||||
SpanDetailVariant,
|
||||
VISIBLE_ACTIONS,
|
||||
} from './constants';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
LinkedSpansToggle,
|
||||
useLinkedSpans,
|
||||
} from './LinkedSpans/LinkedSpans';
|
||||
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
|
||||
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
|
||||
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
|
||||
|
||||
import './SpanDetailsPanel.styles.scss';
|
||||
|
||||
interface SpanDetailsPanelProps {
|
||||
panelState: DetailsPanelState;
|
||||
selectedSpan: SpanV3 | undefined;
|
||||
variant?: SpanDetailVariant;
|
||||
onVariantChange?: (variant: SpanDetailVariant) => void;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
serviceExecTime?: Record<string, number>;
|
||||
}
|
||||
|
||||
function SpanDetailsContent({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
}: {
|
||||
selectedSpan: SpanV3;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
}): JSX.Element {
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
// Map span attribute actions to PrettyView actions format.
|
||||
// Use the last key in fieldKeyPath (the actual attribute key), not the full display path.
|
||||
const prettyViewCustomActions = useMemo(
|
||||
() =>
|
||||
spanAttributeActions.map((action) => ({
|
||||
key: action.value,
|
||||
label: action.label,
|
||||
icon: action.icon,
|
||||
shouldHide: action.shouldHide,
|
||||
onClick: (context: {
|
||||
fieldKey: string;
|
||||
fieldKeyPath: (string | number)[];
|
||||
fieldValue: unknown;
|
||||
}): void => {
|
||||
const leafKey = getLeafKeyFromPath(context.fieldKeyPath, context.fieldKey);
|
||||
action.callback({
|
||||
key: leafKey,
|
||||
value: String(context.fieldValue),
|
||||
});
|
||||
},
|
||||
})),
|
||||
[spanAttributeActions],
|
||||
);
|
||||
|
||||
// const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
// Key attributes section — commented out as pinning in PrettyView covers this use case.
|
||||
// Uncomment when key attributes need to be shown separately.
|
||||
// const buildKeyAttrMenu = useCallback(
|
||||
// (key: string, value: string): ActionMenuItem[] => {
|
||||
// const items: ActionMenuItem[] = [
|
||||
// {
|
||||
// key: 'copy-value',
|
||||
// label: 'Copy Value',
|
||||
// icon: <Copy size={12} />,
|
||||
// onClick: (): void => {
|
||||
// setCopy(value);
|
||||
// toast.success('Copied to clipboard', {
|
||||
// richColors: true,
|
||||
// position: 'top-right',
|
||||
// });
|
||||
// },
|
||||
// },
|
||||
// ];
|
||||
// spanAttributeActions.forEach((action) => {
|
||||
// if (action.shouldHide && action.shouldHide(key)) {
|
||||
// return;
|
||||
// }
|
||||
// items.push({
|
||||
// key: action.value,
|
||||
// label: action.label,
|
||||
// icon: action.icon,
|
||||
// onClick: (): void => {
|
||||
// action.callback({ key, value });
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
// return items;
|
||||
// },
|
||||
// [spanAttributeActions, setCopy],
|
||||
// );
|
||||
|
||||
const {
|
||||
logs,
|
||||
isLoading: isLogsLoading,
|
||||
isError: isLogsError,
|
||||
isFetching: isLogsFetching,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
} = useSpanContextLogs({
|
||||
traceId: selectedSpan.trace_id,
|
||||
spanId: selectedSpan.span_id,
|
||||
timeRange: {
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
},
|
||||
isDrawerOpen: true,
|
||||
});
|
||||
|
||||
const infraMetadata = useMemo(() => {
|
||||
if (!hasInfraMetadata(selectedSpan)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
|
||||
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
|
||||
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
|
||||
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
|
||||
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
|
||||
};
|
||||
}, [selectedSpan]);
|
||||
|
||||
const handleExplorerPageRedirect = useCallback((): void => {
|
||||
const startTimeMs = (traceStartTime || 0) - FIVE_MINUTES_IN_MS;
|
||||
const endTimeMs = (traceEndTime || 0) + FIVE_MINUTES_IN_MS;
|
||||
|
||||
const traceIdFilter = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'trace-id-filter',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
id: 'trace-id-key',
|
||||
dataType: 'string' as const,
|
||||
isColumn: true,
|
||||
type: '',
|
||||
isJSON: false,
|
||||
} as BaseAutocompleteData,
|
||||
op: '=',
|
||||
value: selectedSpan.trace_id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
filters: traceIdFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
|
||||
searchParams.set(QueryParams.startTime, startTimeMs.toString());
|
||||
searchParams.set(QueryParams.endTime, endTimeMs.toString());
|
||||
|
||||
window.open(
|
||||
`${window.location.origin}${
|
||||
ROUTES.LOGS_EXPLORER
|
||||
}?${searchParams.toString()}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}, [selectedSpan.trace_id, traceStartTime, traceEndTime]);
|
||||
|
||||
const emptyLogsStateConfig = useMemo(
|
||||
() => ({
|
||||
...getEmptyLogsListConfig(() => {}),
|
||||
showClearFiltersButton: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// const keyAttributes = useMemo(() => {
|
||||
// const keys = KEY_ATTRIBUTE_KEYS.traces || [];
|
||||
// const allAttrs: Record<string, string> = {};
|
||||
// Object.entries(selectedSpan.resource || {}).forEach(([k, v]) => {
|
||||
// allAttrs[k] = String(v);
|
||||
// });
|
||||
// Object.entries(selectedSpan.attributes || {}).forEach(([k, v]) => {
|
||||
// allAttrs[k] = String(v);
|
||||
// });
|
||||
// const span = (selectedSpan as unknown) as Record<string, unknown>;
|
||||
// keys.forEach((key) => {
|
||||
// if (!(key in allAttrs) && span[key] != null && span[key] !== '') {
|
||||
// allAttrs[key] = String(span[key]);
|
||||
// }
|
||||
// });
|
||||
// return keys
|
||||
// .filter((key) => allAttrs[key])
|
||||
// .map((key) => ({ key, value: allAttrs[key] }));
|
||||
// }, [selectedSpan]);
|
||||
|
||||
return (
|
||||
<div className="span-details-panel__body">
|
||||
<div className="span-details-panel__details-section">
|
||||
<div className="span-details-panel__span-row">
|
||||
<KeyValueLabel
|
||||
badgeKey="Span name"
|
||||
badgeValue={selectedSpan.name}
|
||||
maxCharacters={50}
|
||||
/>
|
||||
<SpanPercentileBadge
|
||||
loading={percentile.loading}
|
||||
percentileValue={percentile.percentileValue}
|
||||
duration={percentile.duration}
|
||||
spanPercentileData={percentile.spanPercentileData}
|
||||
isOpen={percentile.isOpen}
|
||||
toggleOpen={percentile.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
|
||||
|
||||
{/* Span info: exec time + start time */}
|
||||
<div className="span-details-panel__span-info">
|
||||
<div className="span-details-panel__span-info-item">
|
||||
<Timer size={14} />
|
||||
<span>
|
||||
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
|
||||
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
|
||||
<>
|
||||
{' — '}
|
||||
<strong>
|
||||
{(
|
||||
(selectedSpan.duration_nano * 100) /
|
||||
((traceEndTime - traceStartTime) * 1e6)
|
||||
).toFixed(2)}
|
||||
%
|
||||
</strong>
|
||||
{' of total exec time'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="span-details-panel__span-info-item">
|
||||
<CalendarClock size={14} />
|
||||
<span>
|
||||
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="span-details-panel__span-info-item">
|
||||
<Link2 size={14} />
|
||||
<LinkedSpansToggle
|
||||
count={linkedSpans.count}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
toggleOpen={linkedSpans.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkedSpansPanel
|
||||
linkedSpans={linkedSpans.linkedSpans}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
/>
|
||||
|
||||
{/* Step 6: HighlightedOptions */}
|
||||
<div className="span-details-panel__highlighted-options">
|
||||
{HIGHLIGHTED_OPTIONS.map((option) => {
|
||||
const rendered = option.render(selectedSpan);
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<KeyValueLabel
|
||||
key={option.key}
|
||||
badgeKey={option.label}
|
||||
badgeValue={rendered}
|
||||
direction="column"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step 7: KeyAttributes — commented out, pinning in PrettyView covers this.
|
||||
{keyAttributes.length > 0 && (
|
||||
<div className="span-details-panel__key-attributes">
|
||||
<div className="span-details-panel__key-attributes-label">
|
||||
KEY ATTRIBUTES
|
||||
</div>
|
||||
<div className="span-details-panel__key-attributes-chips">
|
||||
{keyAttributes.map(({ key, value }) => (
|
||||
<ActionMenu
|
||||
key={key}
|
||||
items={buildKeyAttrMenu(key, value)}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<div>
|
||||
<KeyValueLabel badgeKey={key} badgeValue={value} />
|
||||
</div>
|
||||
</ActionMenu>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
*/}
|
||||
|
||||
{/* Step 8: MiniTraceContext */}
|
||||
</div>
|
||||
|
||||
<div className="span-details-panel__tabs-section">
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview">
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" variant="secondary">
|
||||
<ScrollText size={14} /> Events ({selectedSpan.events?.length || 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" variant="secondary">
|
||||
<Logs size={14} /> Logs
|
||||
</TabsTrigger>
|
||||
{infraMetadata && (
|
||||
<TabsTrigger value="metrics" variant="secondary">
|
||||
<ChartColumnBig size={14} /> Metrics
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="span-details-panel__tabs-scroll">
|
||||
<TabsContent value="overview">
|
||||
<DataViewer
|
||||
data={selectedSpan}
|
||||
drawerKey="trace-details"
|
||||
prettyViewProps={{
|
||||
showPinned: true,
|
||||
actions: prettyViewCustomActions,
|
||||
visibleActions: VISIBLE_ACTIONS,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
|
||||
<Events
|
||||
span={{ ...selectedSpan, event: selectedSpan.events } as any}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs">
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.trace_id}
|
||||
spanId={selectedSpan.span_id}
|
||||
timeRange={{
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLogsLoading}
|
||||
isError={isLogsError}
|
||||
isFetching={isLogsFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
{infraMetadata && (
|
||||
<TabsContent value="metrics">
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpanDetailsPanel({
|
||||
panelState,
|
||||
selectedSpan,
|
||||
variant = SpanDetailVariant.DIALOG,
|
||||
onVariantChange,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
serviceExecTime,
|
||||
}: SpanDetailsPanelProps): JSX.Element {
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
|
||||
const headerActions = useMemo((): HeaderAction[] => {
|
||||
const actions: HeaderAction[] = [
|
||||
{
|
||||
key: 'analytics',
|
||||
component: (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefixIcon={<ChartBar size={14} />}
|
||||
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
|
||||
>
|
||||
Analytics
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
// TODO: Add back when driven through separate config for different pages
|
||||
// {
|
||||
// key: 'view-full-trace',
|
||||
// component: (
|
||||
// <Button variant="ghost" size="sm" color="secondary" prefixIcon={<ExternalLink size={14} />} onClick={noop}>
|
||||
// View full trace
|
||||
// </Button>
|
||||
// ),
|
||||
// },
|
||||
// TODO: Add back when used in trace explorer page
|
||||
// {
|
||||
// key: 'nav',
|
||||
// component: (
|
||||
// <div className="span-details-panel__header-nav">
|
||||
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronUp size={14} /></Button>
|
||||
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronDown size={14} /></Button>
|
||||
// </div>
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
|
||||
if (onVariantChange) {
|
||||
const isDocked = variant === SpanDetailVariant.DOCKED;
|
||||
actions.push({
|
||||
key: 'dock-toggle',
|
||||
component: (
|
||||
<Tooltip title={isDocked ? 'Open as floating panel' : 'Dock on the side'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void =>
|
||||
onVariantChange(
|
||||
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}, [variant, onVariantChange]);
|
||||
|
||||
const PANEL_WIDTH = 500;
|
||||
const PANEL_MARGIN_RIGHT = 20;
|
||||
const PANEL_MARGIN_TOP = 25;
|
||||
const PANEL_MARGIN_BOTTOM = 25;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<DetailsHeader
|
||||
title="Span details"
|
||||
onClose={panelState.close}
|
||||
actions={headerActions}
|
||||
className={
|
||||
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
|
||||
}
|
||||
/>
|
||||
{selectedSpan ? (
|
||||
<SpanDetailsContent
|
||||
selectedSpan={selectedSpan}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
) : (
|
||||
<div className="span-details-panel__body">
|
||||
<Skeleton active paragraph={{ rows: 6 }} title={{ width: '60%' }} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const analyticsPanel = (
|
||||
<AnalyticsPanel
|
||||
isOpen={isAnalyticsOpen}
|
||||
onClose={(): void => setIsAnalyticsOpen(false)}
|
||||
serviceExecTime={serviceExecTime}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === SpanDetailVariant.DOCKED) {
|
||||
return (
|
||||
<>
|
||||
<div className="span-details-panel">{content}</div>
|
||||
{analyticsPanel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === SpanDetailVariant.DRAWER) {
|
||||
return (
|
||||
<>
|
||||
<DetailsPanelDrawer
|
||||
isOpen={panelState.isOpen}
|
||||
onClose={panelState.close}
|
||||
className="span-details-panel"
|
||||
>
|
||||
{content}
|
||||
</DetailsPanelDrawer>
|
||||
{analyticsPanel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingPanel
|
||||
isOpen={panelState.isOpen}
|
||||
className="span-details-panel"
|
||||
width={PANEL_WIDTH}
|
||||
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
|
||||
y: PANEL_MARGIN_TOP,
|
||||
}}
|
||||
enableResizing={{
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
left: true,
|
||||
topRight: false,
|
||||
bottomRight: false,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</FloatingPanel>
|
||||
{analyticsPanel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanDetailsPanel;
|
||||
@@ -1,258 +0,0 @@
|
||||
// Badge — wraps a KeyValueLabel, clickable to toggle panel
|
||||
.span-percentile-badge {
|
||||
cursor: pointer;
|
||||
|
||||
// Override key color for the percentile value (p99)
|
||||
.key-value-label__key {
|
||||
color: var(--text-sakura-400, #f56c87);
|
||||
}
|
||||
|
||||
&__loader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Panel — collapsible, renders below the row
|
||||
.span-percentile-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
|
||||
backdrop-filter: blur(20px);
|
||||
margin: 8px 16px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
&-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
|
||||
&-title {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
|
||||
&-highlight {
|
||||
color: var(--text-sakura-400, #f56c87);
|
||||
}
|
||||
|
||||
&-loader {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
margin: 0 4px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__timerange {
|
||||
width: 100%;
|
||||
|
||||
&-select {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__table {
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
&-text {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&-rows {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&-skeleton {
|
||||
.ant-skeleton-title {
|
||||
width: 100% !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.ant-skeleton-paragraph {
|
||||
margin-top: 8px;
|
||||
|
||||
& > li + li {
|
||||
margin-top: 10px;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 4px;
|
||||
|
||||
&-key {
|
||||
flex: 0 0 auto;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&-value {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&-dash {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
margin: 0 8px;
|
||||
border-top: 1px solid transparent;
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--l1-border) 0,
|
||||
var(--l1-border) 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
|
||||
&--current {
|
||||
border-radius: 2px;
|
||||
background: rgba(78, 116, 248, 0.2);
|
||||
|
||||
.span-percentile-panel__table-row-key {
|
||||
color: var(--text-robin-300);
|
||||
}
|
||||
|
||||
.span-percentile-panel__table-row-dash {
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#abbdff 0,
|
||||
#abbdff 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
|
||||
.span-percentile-panel__table-row-value {
|
||||
color: var(--text-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__resource-selector {
|
||||
overflow: hidden;
|
||||
width: calc(100% + 16px);
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: -8px;
|
||||
z-index: 1000;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
&-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&-input {
|
||||
border-radius: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&-items {
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
|
||||
&-value {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
|
||||
import { UseSpanPercentileReturn } from './useSpanPercentile';
|
||||
|
||||
import './SpanPercentile.styles.scss';
|
||||
|
||||
type SpanPercentileBadgeProps = Pick<
|
||||
UseSpanPercentileReturn,
|
||||
| 'loading'
|
||||
| 'percentileValue'
|
||||
| 'duration'
|
||||
| 'spanPercentileData'
|
||||
| 'isOpen'
|
||||
| 'toggleOpen'
|
||||
>;
|
||||
|
||||
function SpanPercentileBadge({
|
||||
loading,
|
||||
percentileValue,
|
||||
duration,
|
||||
spanPercentileData,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
}: SpanPercentileBadgeProps): JSX.Element | null {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="span-percentile-badge__loader">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!spanPercentileData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="span-percentile-badge"
|
||||
onClick={toggleOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
toggleOpen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<KeyValueLabel
|
||||
badgeKey={`p${percentileValue}`}
|
||||
badgeValue={
|
||||
<span className="span-percentile-badge__value">
|
||||
{duration}
|
||||
{isOpen ? (
|
||||
<ChevronUp size={14} className="span-percentile-badge__icon" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="span-percentile-badge__icon" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanPercentileBadge;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user