Compare commits
61 Commits
nv/context
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d82aa8eafa | ||
|
|
0ba57f313f | ||
|
|
b7c2c340ab | ||
|
|
5149e162b2 | ||
|
|
6e6d7ebe8e | ||
|
|
5ec7ac728c | ||
|
|
e5d0b5e23d | ||
|
|
3ce72d2bc8 | ||
|
|
aef60bad5d | ||
|
|
ae6a793c3f | ||
|
|
6efd675e33 | ||
|
|
38168efe88 | ||
|
|
cb85269510 | ||
|
|
7d77577d75 | ||
|
|
f1bf33630f | ||
|
|
f2d1530625 | ||
|
|
8bf503a6a8 | ||
|
|
829904eda6 | ||
|
|
dab110af1c | ||
|
|
cfe09bd41c | ||
|
|
793f0c7927 | ||
|
|
0ad0896b09 | ||
|
|
49283aa200 | ||
|
|
c9b9da595d | ||
|
|
cae2a76b6a | ||
|
|
45eae6ca9d | ||
|
|
0c2adc0824 | ||
|
|
244a2b8b52 | ||
|
|
82074b5ccd | ||
|
|
fca2c6f8c8 | ||
|
|
e64afa861a | ||
|
|
653aaa776b | ||
|
|
1c2c4263de | ||
|
|
c00e0eab34 | ||
|
|
764bb8c0a8 | ||
|
|
6bbc23ea12 | ||
|
|
84da2a9b78 | ||
|
|
030a7f7455 | ||
|
|
e0a9921f07 | ||
|
|
a4fe5e87b9 | ||
|
|
f6e58178ea | ||
|
|
ee28ab71ea | ||
|
|
4299d74607 | ||
|
|
19475d899d | ||
|
|
67ed79bab2 | ||
|
|
86a518fb3c | ||
|
|
176b98d549 | ||
|
|
53f6f2352c | ||
|
|
8051f719a9 | ||
|
|
9658abc0b4 | ||
|
|
a29fbca665 | ||
|
|
2a67052a61 | ||
|
|
e49b1ccc43 | ||
|
|
4026094e91 | ||
|
|
ecf17b322b | ||
|
|
038b45394d | ||
|
|
f88b18aef8 | ||
|
|
c33577dfd9 | ||
|
|
dc01e3a4a1 | ||
|
|
56e849ba53 | ||
|
|
a65e34d799 |
@@ -8046,6 +8046,31 @@ components:
|
||||
- metric
|
||||
- value
|
||||
type: object
|
||||
PromotetypesIndexMaterializedPathsResult:
|
||||
properties:
|
||||
indexed:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
skipped:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- indexed
|
||||
- skipped
|
||||
type: object
|
||||
PromotetypesPostableIndexMaterializedPaths:
|
||||
properties:
|
||||
dryRun:
|
||||
type: boolean
|
||||
signal:
|
||||
type: string
|
||||
required:
|
||||
- signal
|
||||
type: object
|
||||
PromotetypesPromotePath:
|
||||
properties:
|
||||
context:
|
||||
@@ -14072,6 +14097,67 @@ paths:
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/promoted_paths/materialized:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint indexes the JSON sub-columns of materialized paths
|
||||
whose columns are indexed and returns the indexed and skipped paths.
|
||||
operationId: IndexMaterializedPaths
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PromotetypesPostableIndexMaterializedPaths'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/PromotetypesIndexMaterializedPathsResult'
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
- tokenizer:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
summary: Index materialized paths
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/roles:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -18914,9 +19000,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: Run Infra Monitoring Setup Checks
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -18988,9 +19074,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Clusters for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19072,9 +19158,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List DaemonSets for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19150,9 +19236,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Deployments for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19218,9 +19304,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Hosts for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19300,9 +19386,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Jobs for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19381,9 +19467,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Kubernetes Containers for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19452,9 +19538,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Namespaces for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19527,9 +19613,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Nodes for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19603,9 +19689,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Pods for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19674,9 +19760,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Volumes for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19752,9 +19838,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List StatefulSets for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
|
||||
@@ -142,7 +142,7 @@ const preview: Preview = {
|
||||
'Logs',
|
||||
['Explorer', 'Live Tail', 'Saved Views', 'Pipelines', 'Settings'],
|
||||
'Traces',
|
||||
['Explorer', 'Trace Details', 'Funnel Details'],
|
||||
['Explorer', 'Trace Details', ['Overview', 'Thread'], 'Funnel Details'],
|
||||
'Metrics',
|
||||
['Explorer'],
|
||||
'Infrastructure',
|
||||
|
||||
@@ -18,8 +18,10 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
IndexMaterializedPaths200,
|
||||
ListPromotedPaths200,
|
||||
ListPromotedPathsParams,
|
||||
PromotetypesPostableIndexMaterializedPathsDTO,
|
||||
PromotetypesPromotePathDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -230,3 +232,87 @@ export const usePromotePaths = <
|
||||
> => {
|
||||
return useMutation(getPromotePathsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint indexes the JSON sub-columns of materialized paths whose columns are indexed and returns the indexed and skipped paths.
|
||||
* @summary Index materialized paths
|
||||
*/
|
||||
export const indexMaterializedPaths = (
|
||||
promotetypesPostableIndexMaterializedPathsDTO?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<IndexMaterializedPaths200>({
|
||||
url: `/api/v1/promoted_paths/materialized`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: promotetypesPostableIndexMaterializedPathsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getIndexMaterializedPathsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['indexMaterializedPaths'];
|
||||
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 indexMaterializedPaths>>,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return indexMaterializedPaths(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type IndexMaterializedPathsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>
|
||||
>;
|
||||
export type IndexMaterializedPathsMutationBody =
|
||||
| BodyType<PromotetypesPostableIndexMaterializedPathsDTO>
|
||||
| undefined;
|
||||
export type IndexMaterializedPathsMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Index materialized paths
|
||||
*/
|
||||
export const useIndexMaterializedPaths = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getIndexMaterializedPathsMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -9878,6 +9878,28 @@ export interface PromotetypesPromotePathDTO {
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export interface PromotetypesIndexMaterializedPathsResultDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
indexed: PromotetypesPromotePathDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
skipped: PromotetypesPromotePathDTO[] | null;
|
||||
}
|
||||
|
||||
export interface PromotetypesPostableIndexMaterializedPathsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
dryRun?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -13250,6 +13272,14 @@ export type ListPromotedPaths200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type IndexMaterializedPaths200 = {
|
||||
data: PromotetypesIndexMaterializedPathsResultDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -61,7 +61,11 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
],
|
||||
values: [
|
||||
{ timestamp: 1000, value: 10 } as unknown as TimeSeriesValue,
|
||||
{ timestamp: 2000, value: 12 } as unknown as TimeSeriesValue,
|
||||
{
|
||||
timestamp: 2000,
|
||||
value: 12,
|
||||
partial: true,
|
||||
} as unknown as TimeSeriesValue,
|
||||
],
|
||||
} as unknown as TimeSeries,
|
||||
],
|
||||
@@ -104,7 +108,7 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
labels: { 'service.name': 'adservice' },
|
||||
values: [
|
||||
{ timestamp: 1000, value: '10' },
|
||||
{ timestamp: 2000, value: '12' },
|
||||
{ timestamp: 2000, value: '12', partial: true },
|
||||
],
|
||||
metaData: expect.objectContaining({
|
||||
alias: '__result_0',
|
||||
|
||||
@@ -125,6 +125,7 @@ function convertTimeSeriesData(
|
||||
values: series.values.map((value: any) => ({
|
||||
timestamp: value.timestamp,
|
||||
value: String(value.value),
|
||||
...(value.partial ? { partial: true } : {}),
|
||||
})),
|
||||
metaData: {
|
||||
alias,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#29F1FB" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
|
||||
<path fill="#0E9AA7" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,3 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#9CA3AF" height="34" viewBox="0 0 131 34">
|
||||
<path fill="#9CA3AF" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35ZM44.41 14.7c-.5-.5-1.1-.9-1.76-1.18a5.62 5.62 0 0 0-4.6.17c-.73.37-1.32.91-1.75 1.62h-.17V8.59H34.1v16.83h2.04v-1.81h.17c.21.36.47.67.77.94.31.25.65.48 1.01.67.37.18.77.31 1.18.39a6.2 6.2 0 0 0 3.39-.24 5.36 5.36 0 0 0 3.02-3.1c.29-.75.44-1.62.44-2.6v-.47c0-.96-.16-1.83-.47-2.58-.3-.75-.7-1.4-1.23-1.9v-.01Zm-5.87.66a3.9 3.9 0 0 1 4.34.84c.36.35.64.8.83 1.3.2.5.3 1.07.3 1.7v.47c0 .64-.1 1.23-.3 1.74a3.75 3.75 0 0 1-2.06 2.15 4.27 4.27 0 0 1-3.12-.03 3.86 3.86 0 0 1-2.09-2.2c-.2-.52-.3-1.11-.3-1.75v-.29c0-.62.1-1.2.3-1.7v-.01c.21-.53.5-.99.84-1.36.36-.37.78-.66 1.26-.86ZM97.04 8.59H95v4.86h-2.94v1.86H95v8.17c0 .56.17 1.03.53 1.4.37.35.84.54 1.4.54h4.18v-1.87h-3.5c-.2 0-.33-.05-.43-.15-.1-.1-.14-.27-.14-.49v-7.6h4.65v-1.86h-4.65V8.59ZM114.61 15a5.48 5.48 0 0 0-1.8-1.33 5.6 5.6 0 0 0-2.57-.56 6.17 6.17 0 0 0-4.26 1.7 5.6 5.6 0 0 0-1.72 4.2v.57c0 .9.15 1.75.44 2.5a5.58 5.58 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.35 5.35 0 0 0 1.91-2.73l.03-.07-1.94-.52-.02.07c-.11.33-.27.64-.46.94-.17.27-.4.52-.7.74-.28.22-.63.39-1.04.51-.41.13-.9.19-1.46.19a3.8 3.8 0 0 1-2.84-1.05 4.07 4.07 0 0 1-1.1-2.7h9.68v-1.6c0-.54-.11-1.12-.34-1.75a5.04 5.04 0 0 0-1.03-1.74Zm-8.25 3.21a3.8 3.8 0 0 1 1.22-2.25 4.19 4.19 0 0 1 3.99-.7c.44.16.83.38 1.17.66.34.27.62.62.82 1.02.21.38.34.8.38 1.27h-7.58ZM129.09 14.42a4.47 4.47 0 0 0-3.37-1.3c-.93 0-1.73.2-2.4.59-.64.39-1.15.97-1.52 1.74h-.17v-2h-2.04v11.97h2.04v-6.23c0-1.26.32-2.28.95-3.02a3.31 3.31 0 0 1 2.65-1.14c.94 0 1.7.3 2.24.9.56.6.83 1.52.83 2.74v6.75h2.04v-7.13c0-1.71-.42-3.02-1.25-3.87ZM88.1 15a5.48 5.48 0 0 0-1.78-1.33 5.6 5.6 0 0 0-2.58-.56 6.17 6.17 0 0 0-4.27 1.7 5.59 5.59 0 0 0-1.71 4.2v.56c0 .92.14 1.76.44 2.51a5.6 5.6 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.36 5.36 0 0 0 1.91-2.73l.03-.07-1.94-.52-.03.07c-.1.32-.26.64-.45.94-.17.27-.4.52-.7.74-.29.21-.64.39-1.05.51-.4.12-.9.19-1.45.19a3.8 3.8 0 0 1-2.85-1.05 4.07 4.07 0 0 1-1.09-2.7h9.68v-1.61c0-.53-.12-1.12-.34-1.74A5.03 5.03 0 0 0 88.1 15Zm-8.24 3.21a3.83 3.83 0 0 1 1.22-2.25 4.2 4.2 0 0 1 3.99-.7c.44.16.83.38 1.16.66.35.27.62.62.83 1.02.2.38.33.8.37 1.27h-7.57ZM73.65 19.42a6.11 6.11 0 0 0-3.23-1.02 6.63 6.63 0 0 1-2.68-.58c-.47-.3-.7-.7-.7-1.25 0-.27.08-.5.21-.7.14-.2.33-.38.56-.52a4.05 4.05 0 0 1 1.78-.42c.85 0 1.54.21 2.06.63.53.41.83 1 .91 1.73l.01.1 1.95-.47-.01-.07c-.07-.45-.22-.91-.45-1.36a3.46 3.46 0 0 0-.94-1.2 4.6 4.6 0 0 0-1.52-.84 6.05 6.05 0 0 0-2.1-.34c-.58 0-1.13.07-1.65.22-.52.14-1 .36-1.43.65-.41.3-.74.66-.99 1.1a2.9 2.9 0 0 0-.37 1.49v.14c0 1.06.38 1.87 1.14 2.42a6.2 6.2 0 0 0 3.24.97c1.18.08 2.04.26 2.56.54.5.28.75.72.75 1.36 0 .6-.25 1.06-.78 1.4-.52.32-1.22.48-2.1.48a3.68 3.68 0 0 1-2.46-.79 3.13 3.13 0 0 1-1.04-2.14v-.09l-1.93.46h-.02v.07a4.4 4.4 0 0 0 3.1 4c.7.24 1.52.36 2.46.36.7 0 1.35-.09 1.93-.27.6-.16 1.12-.4 1.53-.72A3.38 3.38 0 0 0 74.8 22v-.14c0-1.07-.39-1.9-1.14-2.44ZM60.25 23.4c-.1-.1-.14-.27-.14-.49v-9.46h-2.05v1.85h-.16a3.78 3.78 0 0 0-1.61-1.63 4.62 4.62 0 0 0-2.26-.56c-.77 0-1.5.14-2.19.41a5.27 5.27 0 0 0-3.02 3.12c-.29.75-.44 1.63-.44 2.6v.38c0 .99.15 1.87.44 2.63.3.75.7 1.4 1.2 1.93a5.48 5.48 0 0 0 4.05 1.57c.8 0 1.51-.2 2.2-.58.69-.38 1.24-.97 1.63-1.75h.16v.06c0 .56.18 1.03.53 1.4.37.35.85.54 1.41.54h1.36v-1.87h-.68c-.2 0-.34-.05-.43-.15Zm-4.46.13c-.46.2-.97.3-1.52.3a3.68 3.68 0 0 1-2.75-1.09 4.42 4.42 0 0 1-1.05-3.12v-.38c0-.62.1-1.2.29-1.71a3.65 3.65 0 0 1 5-2.17c.47.2.88.49 1.21.86.35.37.62.83.8 1.36.2.5.3 1.08.3 1.7v.3c0 .63-.1 1.23-.3 1.76-.18.5-.45.96-.78 1.33-.33.37-.73.66-1.2.86Z"/>
|
||||
</svg>
|
||||
<svg role="img" viewBox="-2.625 3 28 28" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 451 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" id="图层_1" x="0" y="0" version="1.1" viewBox="0 0 519.27 214.14"><style>.st2{fill:#a9cc54}</style><path d="M493.54 171.25c-35.63 0-64.52-28.89-64.52-64.52s28.89-64.52 64.52-64.52c3.85 0 7.62.36 11.29 1L493.54 5.45H104.46v.02C49.24 6.51 4.78 51.59 4.78 107.07c0 55.47 44.45 100.56 99.68 101.59v.02h389.07l11.29-38.44c-3.66.65-7.43 1.01-11.28 1.01" style="fill:#b6de64"/><defs><path id="SVGID_1_" d="M493.54 171.25c-35.63 0-64.52-28.89-64.52-64.52s28.89-64.52 64.52-64.52c3.85 0 7.62.36 11.29 1V5.45H104.46v.02C49.24 6.51 4.78 51.59 4.78 107.07c0 55.47 44.45 100.56 99.68 101.59v.02h389.07l11.29-38.44c-3.66.65-7.43 1.01-11.28 1.01"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" style="overflow:visible"/></clipPath><g style="clip-path:url(#SVGID_2_)"><path d="M-23.16 55.58H428.5v51.1H-23.16zM-23.16 157.59h530.98v72.35H-23.16z" class="st2"/></g><path d="M493.54 184.23c-43.94 0-79.56-33.62-79.56-77.56s35.62-76.56 79.56-76.56" style="fill:none;stroke:#ddf4a4;stroke-width:49;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#37814A" d="M2.303 0A2.298 2.298 0 0 0 0 2.303v19.394A2.298 2.298 0 0 0 2.303 24h19.394A2.298 2.298 0 0 0 24 21.697V2.303A2.298 2.298 0 0 0 21.697 0zm8.177 3.072c4.098 0 7.028 1.438 7.68 1.764l-1.194 2.55c-2.442-1.057-4.993-1.41-5.672-1.41-1.574 0-2.17.922-2.17 1.763v8.494c0 .869.596 1.791 2.17 1.791.679 0 3.23-.38 5.672-1.41l1.194 2.496c-.435.271-3.637 1.818-7.68 1.818-1.112 0-4.64-.244-4.64-4.64V7.713c0-4.397 3.528-4.64 4.64-4.64z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 529 B |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="lucide/github">
|
||||
<path id="Vector" d="M15 22V18C15.1391 16.7473 14.7799 15.4901 14 14.5C17 14.5 20 12.5 20 9C20.08 7.75 19.73 6.52 19 5.5C19.28 4.35 19.28 3.15 19 2C19 2 18 2 16 3.5C13.36 3 10.64 3 8 3.5C6 2 5 2 5 2C4.7 3.15 4.7 4.35 5 5.5C4.27187 6.51588 3.91847 7.75279 4 9C4 12.5 7 14.5 10 14.5C9.61 14.99 9.32 15.55 9.15 16.15C8.98 16.75 8.93 17.38 9 18M9 18V22M9 18C4.49 20 4 16 2 16" stroke="#C0C1C3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector" d="M15 22V18C15.1391 16.7473 14.7799 15.4901 14 14.5C17 14.5 20 12.5 20 9C20.08 7.75 19.73 6.52 19 5.5C19.28 4.35 19.28 3.15 19 2C19 2 18 2 16 3.5C13.36 3 10.64 3 8 3.5C6 2 5 2 5 2C4.7 3.15 4.7 4.35 5 5.5C4.27187 6.51588 3.91847 7.75279 4 9C4 12.5 7 14.5 10 14.5C9.61 14.99 9.32 15.55 9.15 16.15C8.98 16.75 8.93 17.38 9 18M9 18V22M9 18C4.49 20 4 16 2 16" stroke="#7A8291" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 587 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFEC6E" d="m0 0 11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#A68400" d="m0 0 11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 398 B After Width: | Height: | Size: 398 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="285" height="285" preserveAspectRatio="xMidYMid" viewBox="-14.5 0 285 285"><path fill="#430098" d="M230.4 0c13.996 0 25.37 11.232 25.597 25.177l.003.423v233.244c0 13.996-11.235 25.37-25.177 25.597l-.423.003H25.6c-13.996 0-25.37-11.235-25.597-25.176L0 258.844V25.6C0 11.601 11.235.23 25.177.003L25.6 0zm0 14.222H25.6c-6.174 0-11.218 4.948-11.374 11.085l-.004.293v233.244c0 6.174 4.948 11.218 11.085 11.374l.293.004h204.8c6.177 0 11.218-4.947 11.374-11.084l.004-.294V25.6c0-6.272-5.102-11.378-11.378-11.378M64 184.89l32 28.444-32 28.445zM92.444 42.667v80.75c14.201-4.622 34.052-9.64 53.334-9.64 17.582 0 28.106 6.913 33.838 12.712 12.109 12.25 12.397 27.82 12.381 29.897v.058l.003 85.334h-28.444v-84.942c-.14-6.639-3.34-14.614-17.778-14.614-28.632 0-60.714 14.257-61.666 14.683l-.02.009L64 166.016V42.666zm99.556 0c-1.92 16.156-8.47 31.644-21.333 46.222h-28.445c11.179-14.667 18.198-30.101 21.334-46.222Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="285" height="285" preserveAspectRatio="xMidYMid" viewBox="-14.5 0 285 285"><path fill="#7C4DD6" d="M230.4 0c13.996 0 25.37 11.232 25.597 25.177l.003.423v233.244c0 13.996-11.235 25.37-25.177 25.597l-.423.003H25.6c-13.996 0-25.37-11.235-25.597-25.176L0 258.844V25.6C0 11.601 11.235.23 25.177.003L25.6 0zm0 14.222H25.6c-6.174 0-11.218 4.948-11.374 11.085l-.004.293v233.244c0 6.174 4.948 11.218 11.085 11.374l.293.004h204.8c6.177 0 11.218-4.947 11.374-11.084l.004-.294V25.6c0-6.272-5.102-11.378-11.378-11.378M64 184.89l32 28.444-32 28.445zM92.444 42.667v80.75c14.201-4.622 34.052-9.64 53.334-9.64 17.582 0 28.106 6.913 33.838 12.712 12.109 12.25 12.397 27.82 12.381 29.897v.058l.003 85.334h-28.444v-84.942c-.14-6.639-3.34-14.614-17.778-14.614-28.632 0-60.714 14.257-61.666 14.683l-.02.009L64 166.016V42.666zm99.556 0c-1.92 16.156-8.47 31.644-21.333 46.222h-28.445c11.179-14.667 18.198-30.101 21.334-46.222Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 958 B After Width: | Height: | Size: 958 B |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 463 B |
@@ -1 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LiveKit</title><path d="M14 10h-4v4h4v-4zM18 6h-4v4.001h4v-4zM18 14h-4v4h4v-4zM22 2h-4v4h4V2zM22 18h-4v4h4v-4z" fill="#1FD5F9"></path><path d="M6 18V2H2v20h12v-4H6z" fill="#fff"></path></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M14 10h-4v4h4v-4zM18 6h-4v4.001h4v-4zM18 14h-4v4h4v-4zM22 2h-4v4h4V2zM22 18h-4v4h4v-4z" fill="#1FD5F9"></path><path d="M6 18V2H2v20h12v-4H6z" fill="#7A8291"></path></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 316 B After Width: | Height: | Size: 251 B |
@@ -1 +1 @@
|
||||
<svg width='234' height='42' viewBox='0 0 234 42' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M18 30H6V18H18V30Z' fill='#CFCECD'/><path d='M18 12H6V30H18V12ZM24 36H0V6H24V36Z' fill='#656363'/><path d='M48 30H36V18H48V30Z' fill='#CFCECD'/><path d='M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z' fill='#656363'/><path d='M84 24V30H66V24H84Z' fill='#CFCECD'/><path d='M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z' fill='#656363'/><path d='M108 36H96V18H108V36Z' fill='#CFCECD'/><path d='M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z' fill='#656363'/><path d='M144 30H126V18H144V30Z' fill='#CFCECD'/><path d='M144 12H126V30H144V36H120V6H144V12Z' fill='#211E1E'/><path d='M168 30H156V18H168V30Z' fill='#CFCECD'/><path d='M168 12H156V30H168V12ZM174 36H150V6H174V36Z' fill='#211E1E'/><path d='M198 30H186V18H198V30Z' fill='#CFCECD'/><path d='M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z' fill='#211E1E'/><path d='M234 24V30H216V24H234Z' fill='#CFCECD'/><path d='M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z' fill='#211E1E'/></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M22 24H2V0h20zM17 4.8H7v14.4h10z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 137 B |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.7 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Render</title><path d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507a7.9 7.9 0 0 1-3.825-.979.202.202 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507-1.388 0-2.691-.356-3.825-.979a.2024.2024 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 362 B After Width: | Height: | Size: 376 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" version="1.0" viewBox="0 0 800 600"><path d="M364.7 246.1h16.9L363.2 300h-16.9l-18.5-53.9h17.1l10.2 33h.2zM436 300h-39.4v-53.9h39.2v13.6h-23.6v7.1h20.9V279h-20.9v7.4H436zm41.6-54.8c11.3 0 19.6 5.8 22.1 15.1l-14.7 6.4c-.7-4.2-2.9-7.4-7.6-7.4-5.3 0-9.3 4.4-9.3 14s3.8 14.2 9.3 14.2c4.5 0 6.9-3.4 7.6-7.6l14.7 6.4c-2.5 9.3-10.9 15.1-22.1 15.1-16.2 0-26.1-10.7-26.1-28s9.9-28.2 26.1-28.2m50.3 54.8v-40.3h-16.2v-13.6h48.5v13.6H544V300zm97.7-26.9c0 17.2-10 28-26.1 28-16 0-26.1-10.7-26.1-28s10.2-28 26.1-28c16.1.1 26.1 10.8 26.1 28m-35.4 0c0 8.7 3.3 14.2 9.4 14.2s9.4-5.3 9.4-14.2c0-8.7-3.3-14.2-9.4-14.2s-9.4 5.3-9.4 14.2m84.4 26.9c-1.5-1.8-2-3.4-2.5-6.4l-1.1-6c-.7-3.8-2.5-4.9-5.8-4.9h-6.4V300h-16v-53.9h25.6c12.2 0 20.7 4.7 20.7 16.5 0 7.8-5.3 12.3-11.1 13.4v.2c4.7.7 7.4 3.6 8.5 8.5l2 8.5c.7 2.9 1.6 4.7 3.4 6.7.1.1-17.3.1-17.3.1m-8.2-40.7h-7.8V270h7.8c3.8 0 5.8-1.6 5.8-5.4 0-3.5-1.8-5.3-5.8-5.3m-321.5 81.2c3.3.4 5.6 2.4 5.6 6 0 4.4-2.7 7.3-8.7 7.3h-9.3v-26.1h8.9c5.1 0 8.2 2.4 8.2 6.9 0 3.1-2 5.3-4.7 5.9m-9.1-1.1h5.4c3.3 0 5.1-1.5 5.1-4.5s-1.8-4.5-5.1-4.5h-5.4zm0 11.6h5.8c3.6 0 5.4-1.6 5.4-4.5s-2-4.5-5.4-4.5h-5.8zm28-10.7 7.1-12.7h3.4l-8.9 15.8v10.2h-3.3v-10.2l-9.1-15.8h3.6zm26.7-12.7h7.6c7.3 0 12.7 4 12.7 12.9s-5.4 13.1-12.7 13.1h-7.6zm3.2 23.2h4.4c4.7 0 9.3-2.5 9.3-10.3s-4.5-10.2-9.3-10.2h-4.4zm36.2-4.3H419l-2.5 7.3h-3.3l9.4-26.1h3.6l9.4 26.1h-3.3zm-10-2.9h8.9l-4.4-12.5zm24.1 10.1v-23.2h-8.7v-2.9h20.9v2.9h-8.7v23.2zm28.3-7.2h-10.9l-2.5 7.3h-3.3l9.4-26.1h3.6l9.4 26.1h-3.3zm-9.8-2.9h8.9l-4.4-12.5zm20.7-16h7.6c7.3 0 12.7 4 12.7 12.9s-5.4 13.1-12.7 13.1h-7.6zm3.3 23.2h4.4c4.7 0 9.3-2.5 9.3-10.3s-4.5-10.2-9.3-10.2h-4.4zm44.1-10.1c0 8.4-4.5 13.4-11.3 13.4S508 349 508 340.7c0-8.4 4.5-13.4 11.3-13.4s11.3 5 11.3 13.4m-19 0c0 6.4 2.7 10.7 7.8 10.7s7.8-4.4 7.8-10.7c0-6.4-2.7-10.7-7.8-10.7s-7.8 4.3-7.8 10.7m45-.2v13.1H554l-.5-3.8c-.9 2.7-3.8 4.4-7.3 4.4-6.5 0-11.1-4.9-11.1-13.4 0-8.7 4.9-13.6 11.4-13.6 4.7 0 8 2 9.4 6l-2.9 1.6c-.9-3.1-3.4-4.9-6.7-4.9-5.1 0-8 4-8 10.7 0 6.4 2.5 10.7 8 10.7 3.4 0 6.7-1.8 6.7-6.5V343h-6.4v-2.7h9.8z"/><linearGradient id="a" x1="128.046" x2="313.226" y1="579.207" y2="472.293" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m199.7 408.2-92.2-54.1V245.9l92.2-54.1 92.2 54.1v108.2zm-79.4-61.7 79.1 46.5 79.1-46.5v-92.9l-79-46.5-79.3 46.5c.1-.1.1 92.9.1 92.9" style="fill:url(#a)"/><linearGradient id="b" x1="185.354" x2="277.202" y1="542.013" y2="488.984" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m239.4 270.6-32.3 56.8 10.5 18.5 42.8-75.3zm-36.1 0L171 327.4l10.5 18.5 42.8-75.3zm-64.4 0 42.8 75.3 10.5-18.5-32.3-56.8zm0 0 42.8 75.3 10.5-18.5-32.3-56.8zm64.4 0L171 327.4l10.5 18.5 42.8-75.3z" style="fill:url(#b)"/></svg>
|
||||
<svg role="img" viewBox="89.7 190 220 220" xmlns="http://www.w3.org/2000/svg"><linearGradient id="a" x1="128.046" x2="313.226" y1="579.207" y2="472.293" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m199.7 408.2-92.2-54.1V245.9l92.2-54.1 92.2 54.1v108.2zm-79.4-61.7 79.1 46.5 79.1-46.5v-92.9l-79-46.5-79.3 46.5c.1-.1.1 92.9.1 92.9" style="fill:url(#a)"/><linearGradient id="b" x1="185.354" x2="277.202" y1="542.013" y2="488.984" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m239.4 270.6-32.3 56.8 10.5 18.5 42.8-75.3zm-36.1 0L171 327.4l10.5 18.5 42.8-75.3zm-64.4 0 42.8 75.3 10.5-18.5-32.3-56.8zm0 0 42.8 75.3 10.5-18.5-32.3-56.8zm64.4 0L171 327.4l10.5 18.5 42.8-75.3z" style="fill:url(#b)"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -65,6 +65,21 @@ function Code({
|
||||
);
|
||||
}
|
||||
|
||||
// Untrusted images become links so viewing a message never fetches a third-party URL.
|
||||
function ImageAsLink({
|
||||
src,
|
||||
alt,
|
||||
}: {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<a href={src} target="_blank" rel="noopener noreferrer">
|
||||
{alt || src}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Link({ href, children }: LinkProps): JSX.Element {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
@@ -101,19 +116,22 @@ function MarkdownRenderer({
|
||||
trackCopyAction,
|
||||
elementDetails,
|
||||
className,
|
||||
isTrusted = true,
|
||||
}: {
|
||||
markdownContent: any;
|
||||
variables: any;
|
||||
trackCopyAction?: boolean;
|
||||
elementDetails?: Record<string, unknown>;
|
||||
className?: string;
|
||||
// False for untrusted content (e.g. telemetry): HTML renders as text, images as links.
|
||||
isTrusted?: boolean;
|
||||
}): JSX.Element {
|
||||
const interpolatedMarkdown = interpolateMarkdown(markdownContent, variables);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className={className}
|
||||
rehypePlugins={[rehypeRaw as any]}
|
||||
rehypePlugins={isTrusted ? [rehypeRaw as any] : []}
|
||||
components={{
|
||||
// @ts-expect-error
|
||||
a: Link,
|
||||
@@ -125,6 +143,7 @@ function MarkdownRenderer({
|
||||
}),
|
||||
code: Code,
|
||||
customtag: CustomTag,
|
||||
...(isTrusted ? {} : { img: ImageAsLink }),
|
||||
}}
|
||||
>
|
||||
{interpolatedMarkdown}
|
||||
@@ -135,6 +154,7 @@ function MarkdownRenderer({
|
||||
MarkdownRenderer.defaultProps = {
|
||||
elementDetails: {},
|
||||
trackCopyAction: false,
|
||||
isTrusted: true,
|
||||
};
|
||||
|
||||
export { Code, Link, MarkdownRenderer, Pre };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
|
||||
import { formUrlParams } from 'utils/traceUtils';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
@@ -20,6 +21,7 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
}
|
||||
|
||||
return `${generatePath(ROUTES.TRACE_DETAIL, { id: traceId })}${formUrlParams({
|
||||
tab: TraceDetailsTab.Thread,
|
||||
spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
|
||||
import { getMs } from 'utils/timeUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
@@ -44,7 +45,10 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
if (TRACE_ID_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
to={{
|
||||
pathname: generatePath(ROUTES.TRACE_DETAIL, { id: text }),
|
||||
search: `?tab=${TraceDetailsTab.Thread}`,
|
||||
}}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -560,7 +560,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'springboot',
|
||||
label: 'Spring Boot',
|
||||
imgUrl: springbootUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-springboot/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -570,25 +570,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-springboot/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-springboot/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-springboot/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-springboot/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -597,7 +597,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'tomcat',
|
||||
label: 'Tomcat',
|
||||
imgUrl: tomcatUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-tomcat/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -607,25 +607,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-tomcat/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-tomcat/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-tomcat/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-tomcat/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -634,7 +634,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'jboss',
|
||||
label: 'JBoss/WildFly',
|
||||
imgUrl: jbossUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-jboss/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-jboss/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -644,25 +644,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-jboss/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-jboss/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-jboss/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-jboss/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-jboss/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-jboss/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-jboss/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-jboss/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -671,7 +671,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'opentelemetry-quarkus',
|
||||
label: 'Quarkus',
|
||||
imgUrl: quarkusUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-quarkus/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -681,25 +681,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-quarkus/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-quarkus/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-quarkus/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-quarkus/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -708,7 +708,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'Others',
|
||||
label: 'Others',
|
||||
imgUrl: javaOthersUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-java/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -718,25 +718,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-java/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-java/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-java/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-java/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -872,7 +872,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'nodejs-nestjs',
|
||||
label: 'NodeJs/NestJS',
|
||||
imgUrl: nodejsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-javascript/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -882,25 +882,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-javascript/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-javascript/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-javascript/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-javascript/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -909,7 +909,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'nextjs',
|
||||
label: 'NextJS',
|
||||
imgUrl: nextjsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nextjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -919,25 +919,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nextjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nextjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nextjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nextjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -946,7 +946,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'nuxtjs',
|
||||
label: 'NuxtJS',
|
||||
imgUrl: nuxtjsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -956,25 +956,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -983,7 +983,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'react-native',
|
||||
label: 'React Native',
|
||||
imgUrl: reactjsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-react-native/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-react-native/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1298,7 +1298,7 @@ const onboardingConfigWithLinks = [
|
||||
'resque',
|
||||
],
|
||||
id: 'ruby',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -1308,25 +1308,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2292,7 +2292,7 @@ const onboardingConfigWithLinks = [
|
||||
'python structured logging',
|
||||
'python-logs-auto-instrumentation',
|
||||
],
|
||||
link: '/docs/userguide/python-logs-auto-instrumentation/',
|
||||
link: '/docs/logs-management/send-logs/python-logs/',
|
||||
},
|
||||
{
|
||||
dataSource: 'otel-java-sdk',
|
||||
@@ -2323,7 +2323,7 @@ const onboardingConfigWithLinks = [
|
||||
'Logback',
|
||||
'log4j',
|
||||
],
|
||||
link: '/docs/userguide/collecting_application_logs_otel_sdk_java/',
|
||||
link: '/docs/logs-management/send-logs/java-logs/',
|
||||
},
|
||||
{
|
||||
dataSource: 'nodejs-logs',
|
||||
@@ -2463,7 +2463,7 @@ const onboardingConfigWithLinks = [
|
||||
'linux',
|
||||
],
|
||||
imgUrl: hostmetricsUrl,
|
||||
link: '/docs/userguide/hostmetrics/',
|
||||
link: '/docs/infrastructure-monitoring/user-guides/hostmetrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'k8s-metrics',
|
||||
@@ -2495,7 +2495,7 @@ const onboardingConfigWithLinks = [
|
||||
'telemetry',
|
||||
],
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/userguide/k8s-metrics/',
|
||||
link: '/docs/infrastructure-monitoring/user-guides/k8s-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'docker-container-metrics',
|
||||
@@ -2583,7 +2583,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-ec2-metrics-manual',
|
||||
label: 'Manual Setup',
|
||||
imgUrl: ec2Url,
|
||||
link: '/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/',
|
||||
link: '/docs/opentelemetry-collection-agents/vm/install/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2772,7 +2772,7 @@ const onboardingConfigWithLinks = [
|
||||
'vpc-logs',
|
||||
],
|
||||
imgUrl: vpcUrl,
|
||||
link: '/docs/aws-monitoring/vpc-logs/',
|
||||
link: '/docs/aws-monitoring/vpc/',
|
||||
},
|
||||
{
|
||||
dataSource: 'aws-rds',
|
||||
@@ -3837,7 +3837,7 @@ const onboardingConfigWithLinks = [
|
||||
'traces',
|
||||
'tracing',
|
||||
],
|
||||
link: '/docs/llm/opentelemetry-openai-monitoring/',
|
||||
link: '/docs/openai-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'codex-monitoring',
|
||||
@@ -4779,7 +4779,7 @@ const onboardingConfigWithLinks = [
|
||||
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-flutter/',
|
||||
},
|
||||
{
|
||||
dataSource: 'swift',
|
||||
dataSource: 'ios-swift',
|
||||
label: 'iOS Swift',
|
||||
imgUrl: swiftMonitoringUrl,
|
||||
tags: ['mobile app monitoring'],
|
||||
@@ -4808,6 +4808,7 @@ const onboardingConfigWithLinks = [
|
||||
'traces',
|
||||
'tracing',
|
||||
],
|
||||
id: 'ios-swift',
|
||||
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-swiftui/',
|
||||
},
|
||||
{
|
||||
@@ -5473,7 +5474,7 @@ const onboardingConfigWithLinks = [
|
||||
'telemetry',
|
||||
],
|
||||
id: 'hasura',
|
||||
link: '/docs/integrations/opentelemetry-hasura/',
|
||||
link: '/docs/integrations/outposts/hasura/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opencode-observability',
|
||||
@@ -5935,7 +5936,7 @@ const onboardingConfigWithLinks = [
|
||||
'metrics',
|
||||
'monitoring',
|
||||
],
|
||||
link: '/docs/integrations/flyio/',
|
||||
link: '/docs/integrations/outposts/flyio/',
|
||||
},
|
||||
{
|
||||
dataSource: 'envoy',
|
||||
@@ -6513,7 +6514,7 @@ const onboardingConfigWithLinks = [
|
||||
'tracing',
|
||||
],
|
||||
id: 'openclaw-observability',
|
||||
link: '/docs/openclaw-monitoring/',
|
||||
link: '/docs/openclaw-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'claude-agent-monitoring',
|
||||
@@ -6555,7 +6556,7 @@ const onboardingConfigWithLinks = [
|
||||
'render monitoring',
|
||||
],
|
||||
id: 'render-metrics',
|
||||
link: '/docs/metrics-management/render-metrics/',
|
||||
link: '/docs/integrations/outposts/render/',
|
||||
},
|
||||
{
|
||||
dataSource: 'cert-manager',
|
||||
@@ -6579,7 +6580,7 @@ const onboardingConfigWithLinks = [
|
||||
'tls',
|
||||
],
|
||||
id: 'cert-manager',
|
||||
link: '/docs/infrastructure-monitoring/cert-manager/',
|
||||
link: '/docs/infrastructure-monitoring/user-guides/cert-manager/',
|
||||
},
|
||||
{
|
||||
dataSource: 'pgbouncer',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import {
|
||||
MetricRangePayloadProps,
|
||||
@@ -164,6 +165,27 @@ describe('BarPanel utils', () => {
|
||||
expect(config.series).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('passes partial timestamps to the series', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[1060, '0.2'],
|
||||
],
|
||||
partialTimestamps: [1060],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const series = prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
}).getConfig().series?.[1] as ExtendedSeries;
|
||||
|
||||
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
|
||||
});
|
||||
|
||||
it('uses getLegend for label when currentQuery is provided', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
|
||||
@@ -89,6 +89,9 @@ export function prepareBarPanelConfig({
|
||||
isDarkMode,
|
||||
stepInterval: currentStepInterval,
|
||||
metric: series.metric,
|
||||
partialTimestamps: series.partialTimestamps
|
||||
? new Set(series.partialTimestamps)
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { prepareUPlotConfig } from 'container/WidgetCard/Panels/TimeSeriesPanel/utils';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
@@ -185,6 +186,28 @@ describe('TimeSeriesPanel utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes partial timestamps to the series', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[1060, '0.2'],
|
||||
],
|
||||
partialTimestamps: [1060],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const series = prepareUPlotConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
currentQuery: null as unknown as Query,
|
||||
}).getConfig().series?.[1] as ExtendedSeries;
|
||||
|
||||
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
|
||||
});
|
||||
|
||||
it('uses getLegend for label when currentQuery is provided', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
|
||||
@@ -101,6 +101,9 @@ export const prepareUPlotConfig = ({
|
||||
fillMode: widget.fillMode || FillMode.None,
|
||||
isDarkMode,
|
||||
metric: series.metric,
|
||||
partialTimestamps: series.partialTimestamps
|
||||
? new Set(series.partialTimestamps)
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { convertNewDataToOld } from '../convertNewDataToOld';
|
||||
|
||||
describe('convertNewDataToOld', () => {
|
||||
it('carries partial values as epoch-second partialTimestamps', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
resultType: 'time_series',
|
||||
result: [
|
||||
{
|
||||
queryName: 'A',
|
||||
series: [
|
||||
{
|
||||
labels: {},
|
||||
labelsArray: [],
|
||||
values: [
|
||||
{ timestamp: 1_000_000, value: '1' },
|
||||
{ timestamp: 1_060_000, value: '0.2', partial: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labels: {},
|
||||
labelsArray: [],
|
||||
values: [{ timestamp: 1_000_000, value: '1' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as MetricRangePayloadV3;
|
||||
|
||||
const [partial, complete] = convertNewDataToOld(payload).data.result;
|
||||
|
||||
expect(partial.values).toStrictEqual([
|
||||
[1000, '1'],
|
||||
[1060, '0.2'],
|
||||
]);
|
||||
expect(partial.partialTimestamps).toStrictEqual([1060]);
|
||||
expect(complete).not.toHaveProperty('partialTimestamps');
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,16 @@ export const convertNewDataToOld = (
|
||||
return [...acc, renderValues];
|
||||
}, []);
|
||||
|
||||
const partialTimestamps = series.values
|
||||
.filter((value) => value.partial)
|
||||
.map((value) => value.timestamp / 1000);
|
||||
|
||||
const result: QueryData = {
|
||||
metric: series.labels,
|
||||
values,
|
||||
queryName: `${item.queryName}`,
|
||||
metaData: series.metaData,
|
||||
...(partialTimestamps.length ? { partialTimestamps } : {}),
|
||||
};
|
||||
|
||||
oldResult.push(result);
|
||||
|
||||
@@ -122,6 +122,27 @@ describe('Tooltip', () => {
|
||||
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the header partial when a visible series has a partial bucket at the cursor', () => {
|
||||
const withSeries = (series: unknown[]): uPlot =>
|
||||
Object.assign(createUPlotInstance(0), { series });
|
||||
const partial = { show: true, partialTimestamps: new Set([1]) };
|
||||
|
||||
const { unmount } = renderTooltip({
|
||||
uPlotInstance: withSeries([{}, partial]),
|
||||
});
|
||||
expect(screen.getByTestId('uplot-tooltip-header-partial')).toHaveTextContent(
|
||||
'(partial)',
|
||||
);
|
||||
unmount();
|
||||
|
||||
renderTooltip({
|
||||
uPlotInstance: withSeries([{}, { ...partial, show: false }]),
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('uplot-tooltip-header-partial'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops the date from the header title for a point on the current day', () => {
|
||||
const todayTimestamp = dayjs().tz('UTC').startOf('hour').unix();
|
||||
const uPlotInstance = createUPlotInstance(0, todayTimestamp);
|
||||
|
||||
@@ -13,6 +13,17 @@
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.headerEnd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.partialLabel {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.pinnedItem {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
|
||||
|
||||
import type { ExtendedSeries } from '../../../../config/types';
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
@@ -34,7 +35,7 @@ export default function TooltipHeader({
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
const header = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
return null;
|
||||
}
|
||||
@@ -46,14 +47,21 @@ export default function TooltipHeader({
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return formatTimestampOmittingTodaysDate(
|
||||
timestamp * 1000,
|
||||
resolvedTimezone,
|
||||
dateFormat,
|
||||
);
|
||||
return {
|
||||
title: formatTimestampOmittingTodaysDate(
|
||||
timestamp * 1000,
|
||||
resolvedTimezone,
|
||||
dateFormat,
|
||||
),
|
||||
isPartial: (uPlotInstance.series ?? []).some(
|
||||
(series: ExtendedSeries) =>
|
||||
series.show && series.partialTimestamps?.has(timestamp),
|
||||
),
|
||||
};
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.series,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
dateFormat,
|
||||
@@ -64,10 +72,22 @@ export default function TooltipHeader({
|
||||
className={Styles.headerContainer}
|
||||
data-testid="uplot-tooltip-header-container"
|
||||
>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
{showTooltipHeader && header && (
|
||||
<div className={cx(Styles.headerRow, headerRowClassName)}>
|
||||
<span>{headerTitle}</span>
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
<span>{header.title}</span>
|
||||
{(header.isPartial || isPinned) && (
|
||||
<span className={Styles.headerEnd}>
|
||||
{header.isPartial && (
|
||||
<span
|
||||
className={Styles.partialLabel}
|
||||
data-testid="uplot-tooltip-header-partial"
|
||||
>
|
||||
(partial)
|
||||
</span>
|
||||
)}
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SeriesVisibilityItem } from 'lib/visualization/panels/types';
|
||||
import { getStoredSeriesVisibility } from 'lib/visualization/panels/utils/legendVisibilityUtils';
|
||||
import { partialPointsDrawHook } from 'lib/uPlotV2/hooks/partialPointsDrawHook';
|
||||
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
|
||||
import { resolveSeriesVisibility } from 'lib/uPlotV2/utils/seriesVisibility';
|
||||
@@ -81,6 +82,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private thresholds: Record<string, ThresholdsDrawHookOptions> = {};
|
||||
|
||||
private hasPartialPointsHook = false;
|
||||
|
||||
private tzDate: ((timestamp: number) => Date) | undefined;
|
||||
|
||||
private id: string;
|
||||
@@ -177,6 +180,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
*/
|
||||
addSeries(props: SeriesProps): void {
|
||||
this.series.push(new UPlotSeriesBuilder(props));
|
||||
if (props.partialTimestamps?.size && !this.hasPartialPointsHook) {
|
||||
this.hasPartialPointsHook = true;
|
||||
this.addHook('draw', partialPointsDrawHook);
|
||||
}
|
||||
}
|
||||
|
||||
getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
|
||||
import uPlot, { Series } from 'uplot';
|
||||
|
||||
import { resolveFillOpacity, toAlphaHex } from '../utils/fillOpacity';
|
||||
import { reportBarRect } from '../utils/barRect';
|
||||
import { generateGradientFill } from '../utils/generateGradientFill';
|
||||
import { isolatedPointFilter } from '../utils/seriesPointsFilter';
|
||||
import {
|
||||
@@ -218,7 +219,14 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
}
|
||||
|
||||
getConfig(): ExtendedSeries {
|
||||
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
|
||||
const {
|
||||
scaleKey,
|
||||
label,
|
||||
spanGaps,
|
||||
show = true,
|
||||
metric,
|
||||
partialTimestamps,
|
||||
} = this.props;
|
||||
|
||||
const resolvedLineColor = this.getLineColor();
|
||||
|
||||
@@ -246,6 +254,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
...pathConfig,
|
||||
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
|
||||
metric,
|
||||
...(partialTimestamps?.size ? { partialTimestamps } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -358,6 +367,7 @@ function getBarPathBuilder({
|
||||
builders[barsCfgKey] = barsPathBuilderFactory({
|
||||
size: [barWidthFactor, effectiveBarMaxWidth],
|
||||
align: barAlignment,
|
||||
each: reportBarRect,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -372,6 +382,7 @@ function getBarPathBuilder({
|
||||
builders[barsCfgKey] = barsPathBuilderFactory({
|
||||
size: [barWidthFactor, barMaxWidth],
|
||||
align: barAlignment,
|
||||
each: reportBarRect,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -650,4 +650,20 @@ describe('UPlotConfigBuilder stacking', () => {
|
||||
|
||||
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
|
||||
it('registers the partial points draw hook once, only for series with partial timestamps', () => {
|
||||
const props: SeriesProps = {
|
||||
scaleKey: 'y',
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
};
|
||||
const builder = new UPlotConfigBuilder({ id: 'partial' });
|
||||
builder.addSeries(props);
|
||||
expect(builder.getConfig().hooks?.draw ?? []).toHaveLength(0);
|
||||
|
||||
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
|
||||
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
|
||||
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { reportBarRect } from '../../utils/barRect';
|
||||
import { isolatedPointFilter } from '../../utils/seriesPointsFilter';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, FillMode, LineInterpolation, LineStyle } from '../types';
|
||||
@@ -398,4 +399,29 @@ describe('UPlotSeriesBuilder', () => {
|
||||
|
||||
expect(builder.getConfig().fill).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes partial timestamps only when there are some', () => {
|
||||
const partialTimestamps = new Set([40]);
|
||||
|
||||
expect(
|
||||
new UPlotSeriesBuilder(createBaseProps({ partialTimestamps })).getConfig(),
|
||||
).toHaveProperty('partialTimestamps', partialTimestamps);
|
||||
expect(
|
||||
new UPlotSeriesBuilder(
|
||||
createBaseProps({ partialTimestamps: new Set() }),
|
||||
).getConfig(),
|
||||
).not.toHaveProperty('partialTimestamps');
|
||||
});
|
||||
|
||||
it('reports bar rects from bar path builders', () => {
|
||||
const config = new UPlotSeriesBuilder(
|
||||
createBaseProps({ drawStyle: DrawStyle.Bar, barMaxWidth: 321 }),
|
||||
).getConfig();
|
||||
|
||||
config.paths?.({} as uPlot, 1, 0, 1);
|
||||
|
||||
expect(uPlot.paths.bars).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ each: reportBarRect }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,6 +201,7 @@ export enum FillMode {
|
||||
|
||||
export type ExtendedSeries = Series & {
|
||||
metric?: { [key: string]: string };
|
||||
partialTimestamps?: Set<number>;
|
||||
};
|
||||
|
||||
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
@@ -229,6 +230,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
isDarkMode?: boolean;
|
||||
stepInterval?: number;
|
||||
metric?: { [key: string]: string };
|
||||
/** Epoch-second x values whose bucket doesn't cover a full step; marked with a dotted ring. */
|
||||
partialTimestamps?: Set<number>;
|
||||
}
|
||||
|
||||
export interface LegendItem {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { reportBarRect } from '../../utils/barRect';
|
||||
import { partialPointsDrawHook } from '../partialPointsDrawHook';
|
||||
|
||||
function createPlot(
|
||||
series: Partial<ExtendedSeries>,
|
||||
values: (number | null)[] = [1, 2, 3],
|
||||
): { u: uPlot; ctx: Record<string, jest.Mock> } {
|
||||
const ctx = {
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
beginPath: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
arc: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
strokeRect: jest.fn(),
|
||||
};
|
||||
const xValues = [10, 20, 30];
|
||||
const u = {
|
||||
ctx,
|
||||
bbox: { left: 0, top: 0, width: 100, height: 100 },
|
||||
data: [xValues, values],
|
||||
series: [{}, { show: true, stroke: 'red', points: { size: 4 }, ...series }],
|
||||
valToIdx: (value: number): number => xValues.indexOf(value),
|
||||
valToPos: (value: number): number => value,
|
||||
} as unknown as uPlot;
|
||||
return { u, ctx };
|
||||
}
|
||||
|
||||
describe('partialPointsDrawHook', () => {
|
||||
it('draws a dot and a dotted ring on each partial point', () => {
|
||||
const { u, ctx } = createPlot({ partialTimestamps: new Set([30]) });
|
||||
|
||||
partialPointsDrawHook(u);
|
||||
|
||||
expect(ctx.arc).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.arc).toHaveBeenCalledWith(30, 3, 2, 0, 2 * Math.PI);
|
||||
expect(ctx.arc).toHaveBeenCalledWith(30, 3, 5, 0, 2 * Math.PI);
|
||||
expect(ctx.setLineDash).toHaveBeenCalledWith([2, 2]);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips hidden series, null values and timestamps not in the data', () => {
|
||||
const hidden = createPlot({ show: false, partialTimestamps: new Set([30]) });
|
||||
const missing = createPlot({ partialTimestamps: new Set([30]) }, [
|
||||
1,
|
||||
2,
|
||||
null,
|
||||
]);
|
||||
const unknown = createPlot({ partialTimestamps: new Set([40]) });
|
||||
|
||||
[hidden, missing, unknown].forEach(({ u, ctx }) => {
|
||||
partialPointsDrawHook(u);
|
||||
expect(ctx.arc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('draws a dotted outline around a partial bar', () => {
|
||||
const { u, ctx } = createPlot({
|
||||
partialTimestamps: new Set([30]),
|
||||
paths: (plot, seriesIdx, idx) => {
|
||||
reportBarRect(plot, seriesIdx, idx, 10, 20, 8, 30);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
partialPointsDrawHook(u);
|
||||
|
||||
expect(ctx.strokeRect).toHaveBeenCalledWith(7, 17, 14, 36);
|
||||
expect(ctx.arc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
89
frontend/src/lib/uPlotV2/hooks/partialPointsDrawHook.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ExtendedSeries } from '../config/types';
|
||||
import { BarRect, measureBar } from '../utils/barRect';
|
||||
|
||||
const DEFAULT_POINT_SIZE = 5;
|
||||
const RING_GAP = 3;
|
||||
const RING_WIDTH = 1.5;
|
||||
const RING_DASH = [2, 2];
|
||||
|
||||
function drawRing(
|
||||
u: uPlot,
|
||||
series: ExtendedSeries,
|
||||
idx: number,
|
||||
value: number,
|
||||
): void {
|
||||
const { ctx } = u;
|
||||
const { pxRatio } = uPlot;
|
||||
const cx = u.valToPos(u.data[0][idx], 'x', true);
|
||||
const cy = u.valToPos(value, series.scale ?? 'y', true);
|
||||
const radius = ((series.points?.size ?? DEFAULT_POINT_SIZE) / 2) * pxRatio;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
ctx.lineWidth = RING_WIDTH * pxRatio;
|
||||
ctx.setLineDash(RING_DASH.map((segment) => segment * pxRatio));
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius + RING_GAP * pxRatio, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawBarOutline(u: uPlot, rect: BarRect): void {
|
||||
const gap = RING_GAP * uPlot.pxRatio;
|
||||
u.ctx.lineWidth = RING_WIDTH * uPlot.pxRatio;
|
||||
u.ctx.setLineDash(RING_DASH.map((segment) => segment * uPlot.pxRatio));
|
||||
u.ctx.strokeRect(
|
||||
rect.left - gap,
|
||||
rect.top - gap,
|
||||
rect.width + 2 * gap,
|
||||
rect.height + 2 * gap,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks each series' partial buckets: a dotted outline around bars, a dot and dotted
|
||||
* ring on line and point series.
|
||||
*/
|
||||
export function partialPointsDrawHook(u: uPlot): void {
|
||||
const { ctx, bbox } = u;
|
||||
const xValues = u.data[0];
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
|
||||
ctx.clip();
|
||||
|
||||
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx += 1) {
|
||||
const series = u.series[seriesIdx] as ExtendedSeries;
|
||||
if (!series.show || !series.partialTimestamps) {
|
||||
continue;
|
||||
}
|
||||
const color =
|
||||
typeof series.stroke === 'function'
|
||||
? series.stroke(u, seriesIdx)
|
||||
: (series.stroke ?? '');
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = series.alpha ?? 1;
|
||||
|
||||
series.partialTimestamps.forEach((timestamp) => {
|
||||
const idx = u.valToIdx(timestamp);
|
||||
const value = u.data[seriesIdx][idx];
|
||||
if (xValues[idx] !== timestamp || value == null) {
|
||||
return;
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
const bar = measureBar(u, seriesIdx, idx);
|
||||
if (bar) {
|
||||
drawBarOutline(u, bar);
|
||||
} else {
|
||||
drawRing(u, series, idx, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
30
frontend/src/lib/uPlotV2/utils/__tests__/barRect.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { measureBar, reportBarRect } from '../barRect';
|
||||
|
||||
function plotWithPaths(paths?: uPlot.Series['paths']): uPlot {
|
||||
return { series: [{}, { paths }] } as unknown as uPlot;
|
||||
}
|
||||
|
||||
describe('measureBar', () => {
|
||||
it('returns the rect the bar paths report for that index', () => {
|
||||
const paths = jest.fn((u: uPlot, seriesIdx: number, idx: number) => {
|
||||
reportBarRect(u, seriesIdx, idx, 10, 20, 8, 30);
|
||||
return null;
|
||||
});
|
||||
const u = plotWithPaths(paths);
|
||||
|
||||
expect(measureBar(u, 1, 4)).toStrictEqual({
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 8,
|
||||
height: 30,
|
||||
});
|
||||
expect(paths).toHaveBeenCalledWith(u, 1, 4, 4);
|
||||
});
|
||||
|
||||
it('returns null for series whose paths report no bar', () => {
|
||||
expect(measureBar(plotWithPaths(jest.fn(() => null)), 1, 0)).toBeNull();
|
||||
expect(measureBar(plotWithPaths(), 1, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
45
frontend/src/lib/uPlotV2/utils/barRect.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
export interface BarRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
let onBarRect: ((rect: BarRect) => void) | null = null;
|
||||
|
||||
/** `each` callback for bar path builders; reports bar rects while {@link measureBar} runs. */
|
||||
// eslint-disable-next-line max-params
|
||||
export function reportBarRect(
|
||||
_u: uPlot,
|
||||
_seriesIdx: number,
|
||||
_idx: number,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
onBarRect?.({ left, top, width, height });
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas rect of one bar, from re-running the series' paths for that index.
|
||||
* `null` for series not drawn as bars.
|
||||
*/
|
||||
export function measureBar(
|
||||
u: uPlot,
|
||||
seriesIdx: number,
|
||||
idx: number,
|
||||
): BarRect | null {
|
||||
let rect: BarRect | null = null;
|
||||
onBarRect = (measured): void => {
|
||||
rect = measured;
|
||||
};
|
||||
try {
|
||||
u.series[seriesIdx].paths?.(u, seriesIdx, idx, idx);
|
||||
} finally {
|
||||
onBarRect = null;
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
getPartialTimestamps,
|
||||
hasSingleVisiblePoint,
|
||||
toClickPluginPayload,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
@@ -155,6 +156,7 @@ function addSeries({
|
||||
fillOpacity,
|
||||
isDarkMode,
|
||||
metric: s.labels,
|
||||
partialTimestamps: getPartialTimestamps(s.values),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
|
||||
import { buildBarChartConfig } from '../buildConfig';
|
||||
|
||||
describe('buildBarChartConfig partial buckets', () => {
|
||||
it('passes partial timestamps (epoch seconds) to the series', () => {
|
||||
const config = buildBarChartConfig({
|
||||
panelId: 'p1',
|
||||
spec: {} as DashboardtypesBarChartPanelSpecDTO,
|
||||
builderQueries: [],
|
||||
series: [
|
||||
{
|
||||
queryName: 'A',
|
||||
legend: 'A',
|
||||
labels: {},
|
||||
values: [
|
||||
{ timestamp: 1000, value: 1 },
|
||||
{ timestamp: 61000, value: 0.2, partial: true },
|
||||
],
|
||||
kind: 'series',
|
||||
aggregation: { index: 0, alias: 'A' },
|
||||
},
|
||||
],
|
||||
isDarkMode: false,
|
||||
timezone: UTC_TIMEZONE,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
}).getConfig().series?.[1] as ExtendedSeries;
|
||||
|
||||
expect(config.partialTimestamps).toStrictEqual(new Set([61]));
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { toClickPluginPayload } from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
import {
|
||||
getPartialTimestamps,
|
||||
toClickPluginPayload,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
@@ -102,6 +105,7 @@ function addSeries({
|
||||
isDarkMode,
|
||||
stepInterval,
|
||||
metric: s.labels,
|
||||
partialTimestamps: getPartialTimestamps(s.values),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
@@ -74,3 +75,27 @@ describe('buildTimeSeriesConfig spanGaps', () => {
|
||||
).toStrictEqual([10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTimeSeriesConfig partial buckets', () => {
|
||||
it('passes partial timestamps (epoch seconds) to the series', () => {
|
||||
const config = buildTimeSeriesConfig({
|
||||
panelId: 'p1',
|
||||
spec: {} as DashboardtypesTimeSeriesPanelSpecDTO,
|
||||
builderQueries: [],
|
||||
series: [
|
||||
{
|
||||
...series[0],
|
||||
values: [
|
||||
{ timestamp: 1000, value: 1 },
|
||||
{ timestamp: 61000, value: 0.2, partial: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
isDarkMode: false,
|
||||
timezone: UTC_TIMEZONE,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
}).getConfig().series?.[1] as ExtendedSeries;
|
||||
|
||||
expect(config.partialTimestamps).toStrictEqual(new Set([61]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { resolveSpanGaps } from 'pages/DashboardPage/DashboardContainer/Panels/u
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
getPartialTimestamps,
|
||||
hasSingleVisiblePoint,
|
||||
toClickPluginPayload,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
@@ -149,6 +150,7 @@ function addSeries({
|
||||
fillMode,
|
||||
isDarkMode,
|
||||
metric: s.labels,
|
||||
partialTimestamps: getPartialTimestamps(s.values),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PanelSeries } from '../types';
|
||||
import {
|
||||
getPartialTimestamps,
|
||||
hasSingleVisiblePoint,
|
||||
prepareAlignedData,
|
||||
toClickPluginPayload,
|
||||
@@ -85,6 +86,22 @@ describe('hasSingleVisiblePoint', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPartialTimestamps', () => {
|
||||
it('returns partial timestamps in epoch seconds', () => {
|
||||
expect(
|
||||
getPartialTimestamps([
|
||||
{ timestamp: 1_000_500, value: 1, partial: true },
|
||||
{ timestamp: 2_000_000, value: 2 },
|
||||
{ timestamp: 3_000_000, value: 3, partial: true },
|
||||
]),
|
||||
).toStrictEqual(new Set([1000, 3000]));
|
||||
});
|
||||
|
||||
it('returns undefined when nothing is partial', () => {
|
||||
expect(getPartialTimestamps([{ timestamp: 1000, value: 1 }])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toClickPluginPayload', () => {
|
||||
it('produces the tuple-shaped legacy result the shared click plugin reads', () => {
|
||||
const payload = toClickPluginPayload([
|
||||
|
||||
@@ -41,6 +41,19 @@ export function prepareAlignedData(series: PanelSeries[]): uPlot.AlignedData {
|
||||
return [timestamps, ...yValues] as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
/** Epoch-second x values of the series' partial buckets; `undefined` when none are partial. */
|
||||
export function getPartialTimestamps(
|
||||
values: PanelSeriesPoint[],
|
||||
): Set<number> | undefined {
|
||||
const timestamps = new Set<number>();
|
||||
values.forEach((point) => {
|
||||
if (point.partial) {
|
||||
timestamps.add(Math.floor(point.timestamp / 1000));
|
||||
}
|
||||
});
|
||||
return timestamps.size ? timestamps : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the series has at most one finite point — can't draw a line, so renderers degrade to
|
||||
* points (V1 `hasSingleVisiblePoint` parity).
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// Design token Paragraph/Base/400.
|
||||
@mixin paragraph-base {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.roleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Typography sets size, weight and line height; the mixin adds the rest.
|
||||
.roleLabel {
|
||||
@include paragraph-base;
|
||||
}
|
||||
|
||||
.parts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-style: italic;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.textBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
@include paragraph-base;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
word-break: break-word;
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
:global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(:is(ol, ul)) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
padding-left: var(--spacing-10);
|
||||
}
|
||||
|
||||
:global(li::marker) {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
:global(:is(h1, h2, h3, h4, h5, h6)) {
|
||||
margin: var(--spacing-6) 0 var(--spacing-4);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(:is(h1, h2)) {
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
}
|
||||
|
||||
:global(:is(h1, h2, h3, h4, h5, h6):first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:global(strong) {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
:global(code:not(pre code)) {
|
||||
padding: 0 var(--spacing-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l3-background);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(pre) {
|
||||
position: relative;
|
||||
margin: 0 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
// The highlighter sets its box with inline styles; syntax colours stay its own.
|
||||
:global(pre > div:not(.code-copy-btn)) {
|
||||
margin: 0 !important;
|
||||
padding: var(--spacing-4) var(--spacing-5) !important;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2) !important;
|
||||
background: var(--l1-background) !important;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:global(pre code) {
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
:global(blockquote) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
padding-left: var(--spacing-5);
|
||||
border-left: 2px solid var(--l3-border);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
:global(hr) {
|
||||
margin: var(--spacing-6) 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.plainText {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.showMore {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
min-width: 0;
|
||||
|
||||
@include paragraph-base;
|
||||
|
||||
&.isPlain {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex-shrink: 0;
|
||||
width: var(--spacing-3);
|
||||
height: var(--spacing-3);
|
||||
border-radius: var(--radius-round);
|
||||
|
||||
&[data-tone='success'] {
|
||||
background: var(--accent-forest);
|
||||
}
|
||||
|
||||
&[data-tone='warning'] {
|
||||
background: var(--accent-amber);
|
||||
}
|
||||
|
||||
&[data-tone='error'] {
|
||||
background: var(--accent-cherry);
|
||||
}
|
||||
|
||||
&[data-tone='info'] {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chipLabel {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chipSeparator {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.chipValue {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.chipKey {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.toolCall,
|
||||
.toolResult {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
border-radius: var(--radius-2);
|
||||
transition: outline-color 0.3s ease;
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: var(--spacing-1);
|
||||
|
||||
&.isHighlighted {
|
||||
outline-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.toolLink {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-2);
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
.chip {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.toolLinkIcon {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolLinkIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
min-width: 0;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
align-items: center !important;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0 !important;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
// Unboxed: the chevron sits right after the label.
|
||||
:global(.ant-collapse-header-text) {
|
||||
flex: none !important;
|
||||
margin-inline-end: 0 !important;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
@include paragraph-base;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-expand-icon) {
|
||||
height: auto !important;
|
||||
padding-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: var(--spacing-4) 0 0 !important;
|
||||
}
|
||||
|
||||
&.boxed {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
padding: var(--spacing-3) var(--spacing-5) !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header-text) {
|
||||
flex: auto !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 var(--spacing-5) var(--spacing-4) !important;
|
||||
}
|
||||
|
||||
// The block is the box; nested text drops its own.
|
||||
.textBox {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collapsibleChevron {
|
||||
display: flex;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.jsonBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.json {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
cursor: auto;
|
||||
|
||||
:global(.pretty-view) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jsonCollapsed {
|
||||
max-height: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jsonCopy {
|
||||
position: absolute;
|
||||
top: var(--spacing-2);
|
||||
right: var(--spacing-2);
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
import { ThreadMessage } from './types';
|
||||
import CollapsibleBlock from './CollapsibleBlock';
|
||||
import MessagePart from './MessagePart';
|
||||
import PartChip from './PartChip';
|
||||
import RoleLabel from './RoleLabel';
|
||||
import { getFinishReasonTone, getRoleLabel } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
export interface AIThreadMessageProps {
|
||||
message: ThreadMessage;
|
||||
isOutput?: boolean;
|
||||
canLinkToToolCall?: (toolCallKey: string) => boolean;
|
||||
onToolLinkClick?: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
function AIThreadMessage({
|
||||
message,
|
||||
isOutput,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: AIThreadMessageProps): JSX.Element {
|
||||
const { role, parts: messageParts, finish_reason: finishReason } = message;
|
||||
const roleLabel = getRoleLabel(role);
|
||||
const tone = getFinishReasonTone(finishReason);
|
||||
|
||||
const parts = (
|
||||
<div className={styles.parts}>
|
||||
{messageParts.length === 0 ? (
|
||||
<span className={styles.empty}>No content</span>
|
||||
) : (
|
||||
messageParts.map((part, index) => (
|
||||
<MessagePart
|
||||
// Parts have no stable id and never reorder.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
part={part}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.message}
|
||||
data-testid={`ai-thread-message-${role || 'unknown'}`}
|
||||
>
|
||||
{role === 'system' || role === 'developer' ? (
|
||||
<CollapsibleBlock
|
||||
label={roleLabel && <RoleLabel label={roleLabel} />}
|
||||
testId="ai-message-system-toggle"
|
||||
>
|
||||
{parts}
|
||||
</CollapsibleBlock>
|
||||
) : (
|
||||
<>
|
||||
{(roleLabel || isOutput) && (
|
||||
<div className={styles.roleHeader}>
|
||||
{roleLabel && <RoleLabel label={roleLabel} />}
|
||||
{isOutput && (
|
||||
<Badge
|
||||
variant="solid"
|
||||
color={tone === 'error' ? 'danger' : tone}
|
||||
testId="ai-message-output-badge"
|
||||
>
|
||||
Output
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{parts}
|
||||
</>
|
||||
)}
|
||||
{finishReason && (
|
||||
<PartChip
|
||||
label="Finish Reason"
|
||||
value={finishReason}
|
||||
tone={tone}
|
||||
testId="ai-message-finish-reason"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AIThreadMessage.defaultProps = {
|
||||
isOutput: false,
|
||||
canLinkToToolCall: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default AIThreadMessage;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import { Collapse } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const PANEL_KEY = 'content';
|
||||
|
||||
interface CollapsibleBlockProps {
|
||||
label: ReactNode;
|
||||
// Boxed: full-width bordered row with the chevron on the right.
|
||||
isBoxed?: boolean;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function CollapsibleBlock({
|
||||
label,
|
||||
isBoxed = false,
|
||||
testId,
|
||||
children,
|
||||
}: CollapsibleBlockProps): JSX.Element {
|
||||
// Toggling must not count as a click on the message card; the body still does.
|
||||
const stopHeaderClick = (event: MouseEvent): void => {
|
||||
if ((event.target as HTMLElement).closest('.ant-collapse-header')) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div onClick={stopHeaderClick}>
|
||||
<Collapse
|
||||
ghost
|
||||
className={cx(styles.collapsible, isBoxed && styles.boxed)}
|
||||
expandIconPosition="end"
|
||||
expandIcon={({ isActive }): JSX.Element => (
|
||||
<span className={styles.collapsibleChevron} data-testid={testId}>
|
||||
{isActive ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</span>
|
||||
)}
|
||||
items={[{ key: PANEL_KEY, label, children }]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CollapsibleBlock.defaultProps = {
|
||||
isBoxed: false,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default CollapsibleBlock;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { MouseEvent, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { MAX_TEXT_LENGTH } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface ExpandableTextProps {
|
||||
text: string;
|
||||
isMarkdown?: boolean;
|
||||
}
|
||||
|
||||
function ExpandableText({
|
||||
text,
|
||||
isMarkdown = false,
|
||||
}: ExpandableTextProps): JSX.Element {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isTruncated = !isExpanded && text.length > MAX_TEXT_LENGTH;
|
||||
const visibleText = isTruncated ? `${text.slice(0, MAX_TEXT_LENGTH)}…` : text;
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
className={styles.text}
|
||||
markdownContent={visibleText}
|
||||
variables={{}}
|
||||
isTrusted={false}
|
||||
/>
|
||||
) : (
|
||||
<div className={cx(styles.text, styles.plainText)}>{visibleText}</div>
|
||||
)}
|
||||
{text.length > MAX_TEXT_LENGTH && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMore}
|
||||
onClick={handleToggle}
|
||||
data-testid="ai-message-show-more"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ExpandableText.defaultProps = {
|
||||
isMarkdown: false,
|
||||
};
|
||||
|
||||
export default ExpandableText;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { MouseEvent, useMemo, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
|
||||
import { MAX_TEXT_LENGTH } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
// Branch toggles (arrow, key, summary); PrettyView already stops its actions menu.
|
||||
const TREE_TOGGLES = '.pretty-view__nested-row > :not(ul)';
|
||||
const NO_ACTIONS = { leaf: [], nested: [] };
|
||||
|
||||
interface JsonBlockProps {
|
||||
data: object;
|
||||
/** Clamp JSON longer than `MAX_TEXT_LENGTH` behind Show more. */
|
||||
isCollapsible?: boolean;
|
||||
/** Tree levels open on first render; 1 shows top-level keys closed. */
|
||||
expandDepth?: number;
|
||||
}
|
||||
|
||||
// Expanding tree nodes must not count as a click on the message.
|
||||
function JsonBlock({
|
||||
data,
|
||||
isCollapsible = true,
|
||||
expandDepth = 1,
|
||||
}: JsonBlockProps): JSX.Element {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const text = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
const isLong = isCollapsible && text.length > MAX_TEXT_LENGTH;
|
||||
|
||||
const stopToggleClick = (event: MouseEvent): void => {
|
||||
if ((event.target as HTMLElement).closest(TREE_TOGGLES)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.jsonBlock}>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={cx(styles.json, isLong && !isExpanded && styles.jsonCollapsed)}
|
||||
onClick={stopToggleClick}
|
||||
data-testid="ai-json-block"
|
||||
>
|
||||
<span className={styles.jsonCopy}>
|
||||
<CopyButton
|
||||
value={text}
|
||||
size={12}
|
||||
ariaLabel="Copy JSON"
|
||||
testId="ai-json-copy"
|
||||
/>
|
||||
</span>
|
||||
<PrettyView
|
||||
data={data as Record<string, unknown>}
|
||||
searchable={false}
|
||||
drawerKey="ai-thread-message"
|
||||
visibleActions={NO_ACTIONS}
|
||||
expandDepth={expandDepth}
|
||||
/>
|
||||
</div>
|
||||
{isLong && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMore}
|
||||
onClick={handleToggle}
|
||||
data-testid="ai-json-show-more"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
JsonBlock.defaultProps = {
|
||||
isCollapsible: true,
|
||||
expandDepth: 1,
|
||||
};
|
||||
|
||||
export default JsonBlock;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { MessagePart as MessagePartType } from './types';
|
||||
import JsonBlock from './JsonBlock';
|
||||
import ThinkingPart from './parts/ThinkingPart';
|
||||
import ToolCallPart from './parts/ToolCallPart';
|
||||
import ToolResultPart from './parts/ToolResultPart';
|
||||
import TextContent from './TextContent';
|
||||
import {
|
||||
isEmptyPart,
|
||||
toDisplayText,
|
||||
toToolCallKey,
|
||||
truncateBlobContent,
|
||||
} from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const EMPTY_PART = (
|
||||
<span className={styles.empty} data-testid="ai-message-empty-part">
|
||||
No content
|
||||
</span>
|
||||
);
|
||||
|
||||
interface MessagePartProps {
|
||||
part: MessagePartType;
|
||||
canLinkToToolCall?: (toolCallKey: string) => boolean;
|
||||
onToolLinkClick?: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
function MessagePart({
|
||||
part,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagePartProps): JSX.Element {
|
||||
if (isEmptyPart(part)) {
|
||||
return EMPTY_PART;
|
||||
}
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return <TextContent text={part.content} isMarkdown />;
|
||||
case 'reasoning':
|
||||
return <ThinkingPart content={part.content} />;
|
||||
case 'compaction':
|
||||
return (
|
||||
<ThinkingPart
|
||||
label="Compaction"
|
||||
content={part.content ?? undefined}
|
||||
testId="ai-message-compaction-toggle"
|
||||
/>
|
||||
);
|
||||
case 'tool_call':
|
||||
return (
|
||||
<ToolCallPart
|
||||
id={part.id ?? ''}
|
||||
name={part.name ?? ''}
|
||||
args={part.arguments}
|
||||
type={part.type}
|
||||
/>
|
||||
);
|
||||
case 'server_tool_call':
|
||||
return (
|
||||
<ToolCallPart
|
||||
id={part.id ?? ''}
|
||||
name={part.name ?? ''}
|
||||
args={part.server_tool_call}
|
||||
type={part.type}
|
||||
/>
|
||||
);
|
||||
case 'tool_call_response':
|
||||
case 'server_tool_call_response': {
|
||||
const toolCallId = part.id ?? '';
|
||||
const toolCallKey = toToolCallKey(
|
||||
toolCallId,
|
||||
part.type === 'server_tool_call_response',
|
||||
);
|
||||
return (
|
||||
<ToolResultPart
|
||||
toolCallId={toolCallId}
|
||||
toolCallKey={toolCallKey}
|
||||
type={part.type}
|
||||
content={toDisplayText(
|
||||
part.type === 'tool_call_response'
|
||||
? part.response
|
||||
: part.server_tool_call_response,
|
||||
)}
|
||||
isLinkEnabled={!!toolCallId && !!canLinkToToolCall?.(toolCallKey)}
|
||||
onLinkClick={onToolLinkClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'blob':
|
||||
case 'file':
|
||||
case 'uri':
|
||||
return <JsonBlock data={truncateBlobContent(part)} />;
|
||||
case 'generic':
|
||||
return <TextContent text={toDisplayText(part.content)} />;
|
||||
default:
|
||||
return EMPTY_PART;
|
||||
}
|
||||
}
|
||||
|
||||
MessagePart.defaultProps = {
|
||||
canLinkToToolCall: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default MessagePart;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { MessageTone } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface PartChipProps {
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
tone?: MessageTone;
|
||||
// Plain: no chip background, for headers like a tool call's.
|
||||
isPlain?: boolean;
|
||||
testId?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function PartChip({
|
||||
label,
|
||||
value,
|
||||
tone = 'success',
|
||||
isPlain = false,
|
||||
testId,
|
||||
children,
|
||||
}: PartChipProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={cx(styles.chip, isPlain && styles.isPlain)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className={styles.dot} data-tone={tone} />
|
||||
<span className={styles.chipLabel}>{label}</span>
|
||||
{value !== undefined && (
|
||||
<>
|
||||
<span className={styles.chipSeparator}>—</span>
|
||||
<span className={styles.chipValue}>{value}</span>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
PartChip.defaultProps = {
|
||||
value: undefined,
|
||||
tone: 'success',
|
||||
isPlain: false,
|
||||
testId: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default PartChip;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface RoleLabelProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Message role in the design's Paragraph/Base/400 style. */
|
||||
function RoleLabel({ label }: RoleLabelProps): JSX.Element {
|
||||
return (
|
||||
<Typography.Text size="base" weight="normal" className={styles.roleLabel}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleLabel;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import ExpandableText from './ExpandableText';
|
||||
import JsonBlock from './JsonBlock';
|
||||
import { parseJsonObject } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface TextContentProps {
|
||||
text: string;
|
||||
isMarkdown?: boolean;
|
||||
}
|
||||
|
||||
/** Renders JSON text as a tree, anything else as (markdown) text. */
|
||||
function TextContent({
|
||||
text,
|
||||
isMarkdown = false,
|
||||
}: TextContentProps): JSX.Element {
|
||||
const json = useMemo(() => parseJsonObject(text), [text]);
|
||||
|
||||
if (json) {
|
||||
return <JsonBlock data={json} />;
|
||||
}
|
||||
return (
|
||||
<div className={styles.textBox}>
|
||||
<ExpandableText text={text} isMarkdown={isMarkdown} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TextContent.defaultProps = {
|
||||
isMarkdown: false,
|
||||
};
|
||||
|
||||
export default TextContent;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import AIThreadMessage from '../AIThreadMessage';
|
||||
import JsonBlock from '../JsonBlock';
|
||||
import { MAX_TEXT_LENGTH } from '../utils';
|
||||
|
||||
describe('AIThreadMessage', () => {
|
||||
it('shows the Output badge when the message has no role', () => {
|
||||
render(
|
||||
<AIThreadMessage
|
||||
message={{ parts: [{ type: 'text', content: 'hi' }] }}
|
||||
isOutput
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ai-message-output-badge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['blank text', { type: 'text' as const, content: ' ' }],
|
||||
['empty generic', { type: 'generic' as const, content: '' }],
|
||||
])('renders No content for a %s part', (_label, part) => {
|
||||
render(<AIThreadMessage message={{ role: 'user', parts: [part] }} />);
|
||||
|
||||
expect(screen.getByTestId('ai-message-empty-part')).toHaveTextContent(
|
||||
'No content',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JsonBlock', () => {
|
||||
const big = { value: 'x'.repeat(MAX_TEXT_LENGTH) };
|
||||
|
||||
it('collapses large JSON behind Show more', () => {
|
||||
render(<JsonBlock data={big} />);
|
||||
|
||||
const toggle = screen.getByTestId('ai-json-show-more');
|
||||
expect(toggle).toHaveTextContent('Show more');
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveTextContent('Show less');
|
||||
});
|
||||
|
||||
it('does not collapse small or non-collapsible JSON', () => {
|
||||
const { rerender } = render(<JsonBlock data={{ a: 1 }} />);
|
||||
expect(screen.queryByTestId('ai-json-show-more')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<JsonBlock data={big} isCollapsible={false} />);
|
||||
expect(screen.queryByTestId('ai-json-show-more')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts with nested keys collapsed', () => {
|
||||
render(<JsonBlock data={{ outer: { inner: 'hidden-value' } }} />);
|
||||
|
||||
expect(screen.getByText(/outer/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/hidden-value/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a copy button', () => {
|
||||
render(<JsonBlock data={{ a: 1 }} />);
|
||||
|
||||
expect(screen.getByTestId('ai-json-copy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { MessagePart, MessagePartType, ThreadMessage } from './types';
|
||||
import { toDisplayText } from './utils';
|
||||
|
||||
const PART_TYPES = new Set<string>([
|
||||
'text',
|
||||
'reasoning',
|
||||
'compaction',
|
||||
'tool_call',
|
||||
'tool_call_response',
|
||||
'server_tool_call',
|
||||
'server_tool_call_response',
|
||||
'blob',
|
||||
'file',
|
||||
'uri',
|
||||
'generic',
|
||||
] satisfies MessagePartType[]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
function toPart(part: unknown): MessagePart {
|
||||
if (typeof part === 'string') {
|
||||
return { type: 'text', content: part };
|
||||
}
|
||||
if (
|
||||
!isRecord(part) ||
|
||||
typeof part.type !== 'string' ||
|
||||
!PART_TYPES.has(part.type)
|
||||
) {
|
||||
return { type: 'generic', content: part };
|
||||
}
|
||||
switch (part.type) {
|
||||
case 'text': {
|
||||
// Some SDKs write "text" in place of the schema's "content".
|
||||
const text = part.content ?? part.text;
|
||||
return {
|
||||
type: 'text',
|
||||
content: typeof text === 'string' ? text : toDisplayText(text),
|
||||
};
|
||||
}
|
||||
case 'reasoning':
|
||||
return {
|
||||
type: 'reasoning',
|
||||
content: optionalString(part.content ?? part.text),
|
||||
};
|
||||
case 'generic':
|
||||
return { type: 'generic', content: part.content ?? part };
|
||||
default:
|
||||
return part as MessagePart;
|
||||
}
|
||||
}
|
||||
|
||||
function toThreadMessage(item: unknown): ThreadMessage {
|
||||
if (!isRecord(item)) {
|
||||
return { parts: [toPart(item)] };
|
||||
}
|
||||
const body = item.parts ?? item.content;
|
||||
return {
|
||||
role: optionalString(item.role),
|
||||
name: optionalString(item.name),
|
||||
finish_reason: optionalString(item.finish_reason),
|
||||
parts: Array.isArray(body) ? body.map(toPart) : [toPart(body ?? item)],
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalises formatted or raw `gen_ai.*.messages` values into thread messages. */
|
||||
export function toThreadMessages(value: unknown): ThreadMessage[] {
|
||||
if (value === undefined || value === null) {
|
||||
return [];
|
||||
}
|
||||
return (Array.isArray(value) ? value : [value]).map(toThreadMessage);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import CollapsibleBlock from '../CollapsibleBlock';
|
||||
import TextContent from '../TextContent';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ThinkingPartProps {
|
||||
content?: string;
|
||||
label?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ThinkingPart({
|
||||
content,
|
||||
label = 'Thinking',
|
||||
testId = 'ai-message-thinking-toggle',
|
||||
}: ThinkingPartProps): JSX.Element {
|
||||
return (
|
||||
<CollapsibleBlock label={label} isBoxed testId={testId}>
|
||||
{content ? (
|
||||
<TextContent text={content} isMarkdown />
|
||||
) : (
|
||||
<span className={styles.empty}>Content not captured</span>
|
||||
)}
|
||||
</CollapsibleBlock>
|
||||
);
|
||||
}
|
||||
|
||||
ThinkingPart.defaultProps = {
|
||||
content: undefined,
|
||||
label: 'Thinking',
|
||||
testId: 'ai-message-thinking-toggle',
|
||||
};
|
||||
|
||||
export default ThinkingPart;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import JsonBlock from '../JsonBlock';
|
||||
import PartChip from '../PartChip';
|
||||
import TextContent from '../TextContent';
|
||||
import { parseJsonObject, TOOL_CALL_ID_ATTR, toToolCallKey } from '../utils';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ToolCallPartProps {
|
||||
id: string;
|
||||
name: string;
|
||||
args: unknown;
|
||||
type: 'tool_call' | 'server_tool_call';
|
||||
}
|
||||
|
||||
function ToolCallPart({
|
||||
id,
|
||||
name,
|
||||
args,
|
||||
type,
|
||||
}: ToolCallPartProps): JSX.Element {
|
||||
// Streamed calls can carry truncated, unparseable argument strings.
|
||||
const json = useMemo(() => {
|
||||
if (typeof args === 'string') {
|
||||
return parseJsonObject(args);
|
||||
}
|
||||
return typeof args === 'object' && args !== null ? args : undefined;
|
||||
}, [args]);
|
||||
|
||||
const toolCallAttr = {
|
||||
[TOOL_CALL_ID_ATTR]: toToolCallKey(id, type === 'server_tool_call'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.toolCall} {...toolCallAttr} data-testid="ai-tool-call">
|
||||
<PartChip
|
||||
label={type}
|
||||
tone="info"
|
||||
isPlain
|
||||
value={
|
||||
<>
|
||||
<span className={styles.chipKey}>name :</span>
|
||||
<span className={styles.toolName}>{name}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{json ? (
|
||||
<JsonBlock data={json} />
|
||||
) : (
|
||||
args !== undefined &&
|
||||
args !== null && (
|
||||
<TextContent
|
||||
text={typeof args === 'string' ? args : JSON.stringify(args)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolCallPart;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MouseEvent, useMemo } from 'react';
|
||||
import { CornerUpRight } from '@signozhq/icons';
|
||||
|
||||
import PartChip from '../PartChip';
|
||||
import TextContent from '../TextContent';
|
||||
import { MAX_INLINE_RESULT_LENGTH, parseJsonObject } from '../utils';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ToolResultPartProps {
|
||||
toolCallId: string;
|
||||
toolCallKey: string;
|
||||
type: 'tool_call_response' | 'server_tool_call_response';
|
||||
name?: string;
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
isLinkEnabled: boolean;
|
||||
onLinkClick?: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
function ToolResultPart({
|
||||
toolCallId,
|
||||
toolCallKey,
|
||||
type,
|
||||
name,
|
||||
content,
|
||||
isError,
|
||||
isLinkEnabled,
|
||||
onLinkClick,
|
||||
}: ToolResultPartProps): JSX.Element {
|
||||
// Short plain results fit in the chip; JSON or long ones render below it.
|
||||
const isInline = useMemo(
|
||||
() => content.length <= MAX_INLINE_RESULT_LENGTH && !parseJsonObject(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
const handleLinkClick = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
onLinkClick?.(toolCallKey);
|
||||
};
|
||||
|
||||
const chip = (
|
||||
<PartChip
|
||||
label={type}
|
||||
value={isInline ? content || '…' : name || toolCallId}
|
||||
tone={isError ? 'error' : 'success'}
|
||||
>
|
||||
{isLinkEnabled && (
|
||||
<CornerUpRight size={12} className={styles.toolLinkIcon} />
|
||||
)}
|
||||
</PartChip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.toolResult} data-testid="ai-tool-result">
|
||||
{isLinkEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toolLink}
|
||||
onClick={handleLinkClick}
|
||||
title="Go to tool call"
|
||||
data-testid="ai-tool-result-link"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
) : (
|
||||
chip
|
||||
)}
|
||||
{!isInline && <TextContent text={content} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ToolResultPart.defaultProps = {
|
||||
name: undefined,
|
||||
isError: false,
|
||||
onLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default ToolResultPart;
|
||||
52
frontend/src/pages/TraceDetailsV3/AIThreadMessage/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
AiobservabilitytypesBlobPartDTO,
|
||||
AiobservabilitytypesCompactionPartDTO,
|
||||
AiobservabilitytypesFilePartDTO,
|
||||
AiobservabilitytypesOutputMessageDTO,
|
||||
AiobservabilitytypesReasoningPartDTO,
|
||||
AiobservabilitytypesServerToolCallPartDTO,
|
||||
AiobservabilitytypesServerToolCallResponsePartDTO,
|
||||
AiobservabilitytypesTextPartDTO,
|
||||
AiobservabilitytypesToolCallRequestPartDTO,
|
||||
AiobservabilitytypesToolCallResponsePartDTO,
|
||||
AiobservabilitytypesUriPartDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Generated enum `type`s as string literals, so `part.type === 'text'` narrows.
|
||||
type Part<T extends { type: string }> = Omit<T, 'type'> & {
|
||||
type: `${T['type']}`;
|
||||
};
|
||||
|
||||
// Keys the schema marks required may still be missing.
|
||||
type LoosePart<T extends { type: string }> = Partial<Omit<T, 'type'>> & {
|
||||
type: `${T['type']}`;
|
||||
};
|
||||
|
||||
/** OTel GenAI message part; `text` content is always normalised to a string. */
|
||||
export type MessagePart =
|
||||
| Part<AiobservabilitytypesTextPartDTO>
|
||||
| LoosePart<AiobservabilitytypesReasoningPartDTO>
|
||||
| LoosePart<AiobservabilitytypesCompactionPartDTO>
|
||||
| LoosePart<AiobservabilitytypesToolCallRequestPartDTO>
|
||||
| LoosePart<AiobservabilitytypesToolCallResponsePartDTO>
|
||||
| LoosePart<AiobservabilitytypesServerToolCallPartDTO>
|
||||
| LoosePart<AiobservabilitytypesServerToolCallResponsePartDTO>
|
||||
| LoosePart<AiobservabilitytypesBlobPartDTO>
|
||||
| LoosePart<AiobservabilitytypesFilePartDTO>
|
||||
| LoosePart<AiobservabilitytypesUriPartDTO>
|
||||
| { type: 'generic'; content?: unknown };
|
||||
|
||||
export type MessagePartType = MessagePart['type'];
|
||||
|
||||
export type MediaMessagePart = Extract<
|
||||
MessagePart,
|
||||
{ type: 'blob' | 'file' | 'uri' }
|
||||
>;
|
||||
|
||||
// role: system | developer | user | assistant | tool; any other value renders headerless.
|
||||
// finish_reason: stop | tool_call | length | content_filter | compaction | error, or provider-specific.
|
||||
export type ThreadMessage = Partial<
|
||||
Omit<AiobservabilitytypesOutputMessageDTO, 'parts'>
|
||||
> & {
|
||||
parts: MessagePart[];
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
import { TOOL_CALL_ID_ATTR } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const HIGHLIGHT_MS = 1500;
|
||||
|
||||
interface ToolCallLinks {
|
||||
canLinkToToolCall: (toolCallKey: string) => boolean;
|
||||
onToolLinkClick: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
/** Links tool results to their calls in `rootRef`; enabled only for keys in `toolCallKeys`. */
|
||||
export function useToolCallLinks(
|
||||
rootRef: RefObject<HTMLElement>,
|
||||
toolCallKeys: Set<string>,
|
||||
): ToolCallLinks {
|
||||
const canLinkToToolCall = useCallback(
|
||||
(toolCallKey: string): boolean => toolCallKeys.has(toolCallKey),
|
||||
[toolCallKeys],
|
||||
);
|
||||
|
||||
const onToolLinkClick = useCallback(
|
||||
(toolCallKey: string): void => {
|
||||
const target = rootRef.current?.querySelector<HTMLElement>(
|
||||
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallKey)}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add(styles.isHighlighted);
|
||||
window.setTimeout(
|
||||
() => target.classList.remove(styles.isHighlighted),
|
||||
HIGHLIGHT_MS,
|
||||
);
|
||||
},
|
||||
[rootRef],
|
||||
);
|
||||
|
||||
return { canLinkToToolCall, onToolLinkClick };
|
||||
}
|
||||
96
frontend/src/pages/TraceDetailsV3/AIThreadMessage/utils.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { attempt, isError, isObjectLike } from 'lodash-es';
|
||||
|
||||
import { MediaMessagePart, MessagePart, ThreadMessage } from './types';
|
||||
|
||||
export const MAX_TEXT_LENGTH = 1000;
|
||||
|
||||
const MAX_BLOB_PREVIEW_LENGTH = 100;
|
||||
|
||||
export const MAX_INLINE_RESULT_LENGTH = 80;
|
||||
|
||||
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
system: 'System',
|
||||
developer: 'Developer',
|
||||
user: 'User',
|
||||
assistant: 'Assistant',
|
||||
tool: 'Tool',
|
||||
};
|
||||
|
||||
export type MessageTone = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
// Provider values the backend could not map fall back to info.
|
||||
const FINISH_REASON_TONES: Record<string, MessageTone> = {
|
||||
stop: 'success',
|
||||
tool_call: 'success',
|
||||
length: 'warning',
|
||||
content_filter: 'warning',
|
||||
compaction: 'info',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
/** Undefined for missing or unknown roles, which render headerless. */
|
||||
export function getRoleLabel(role?: string): string | undefined {
|
||||
return role ? ROLE_LABELS[role] : undefined;
|
||||
}
|
||||
|
||||
export function getFinishReasonTone(reason?: string | null): MessageTone {
|
||||
return (reason && FINISH_REASON_TONES[reason.toLowerCase()]) || 'info';
|
||||
}
|
||||
|
||||
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
|
||||
export function parseJsonObject(text: string): object | undefined {
|
||||
const parsed: unknown = attempt(JSON.parse, text);
|
||||
return isObjectLike(parsed) && !isError(parsed)
|
||||
? (parsed as object)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function toDisplayText(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
/** Text and generic parts with only whitespace have nothing to render. */
|
||||
export function isEmptyPart(part: MessagePart): boolean {
|
||||
if (part.type === 'text') {
|
||||
return part.content.trim() === '';
|
||||
}
|
||||
if (part.type === 'generic') {
|
||||
return toDisplayText(part.content).trim() === '';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Blob content is inline base64; only a prefix is shown. */
|
||||
export function truncateBlobContent(part: MediaMessagePart): MediaMessagePart {
|
||||
if (
|
||||
part.type !== 'blob' ||
|
||||
!part.content ||
|
||||
part.content.length <= MAX_BLOB_PREVIEW_LENGTH
|
||||
) {
|
||||
return part;
|
||||
}
|
||||
return {
|
||||
...part,
|
||||
content: `${part.content.slice(0, MAX_BLOB_PREVIEW_LENGTH)}…`,
|
||||
};
|
||||
}
|
||||
|
||||
// Server and client calls can share an id, so links match on kind too.
|
||||
export function toToolCallKey(id: string, isServer: boolean): string {
|
||||
return `${isServer ? 'server' : 'client'}:${id}`;
|
||||
}
|
||||
|
||||
export function collectToolCallKeys(messages: ThreadMessage[]): string[] {
|
||||
return messages.flatMap((message) =>
|
||||
message.parts.flatMap((part) =>
|
||||
(part.type === 'tool_call' || part.type === 'server_tool_call') && part.id
|
||||
? [toToolCallKey(part.id, part.type === 'server_tool_call')]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Tooltip, TooltipProps } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: string;
|
||||
tooltip: TooltipProps['title'];
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -16,7 +16,8 @@ function EntityMetadataItem({
|
||||
children,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
// Below the row: above it the tooltip covers the Overview / Thread tabs.
|
||||
<Tooltip title={tooltip} side="bottom">
|
||||
<span className={styles.item}>
|
||||
{icon}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
|
||||
@@ -8,3 +8,12 @@
|
||||
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
|
||||
--typography-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.tokenTooltip {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.tokenTooltipTitle {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatCost,
|
||||
formatTokens,
|
||||
getTotalInputTokens,
|
||||
hasValue,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import EntityMetadataItem from './EntityMetadataItem';
|
||||
import TokenUsageTooltip from './TokenUsageTooltip';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
@@ -16,6 +24,8 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: AiTokenCounts;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -32,8 +42,11 @@ function EntityMetadataRow({
|
||||
execTimePercent,
|
||||
timestamp,
|
||||
statusCode,
|
||||
tokens,
|
||||
cost,
|
||||
}: EntityMetadataRowProps): JSX.Element {
|
||||
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
|
||||
const totalInput = tokens && getTotalInputTokens(tokens);
|
||||
const durationTooltip =
|
||||
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
|
||||
// Single source of duration formatting so both rows label units identically.
|
||||
@@ -91,6 +104,24 @@ function EntityMetadataRow({
|
||||
<HttpStatusBadge statusCode={statusCode} />
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<EntityMetadataItem
|
||||
tooltip={<TokenUsageTooltip tokens={tokens} />}
|
||||
icon={<Coins size={ICON_SIZE} />}
|
||||
>
|
||||
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{hasValue(cost) && (
|
||||
<EntityMetadataItem
|
||||
tooltip="Total cost"
|
||||
icon={<Landmark size={ICON_SIZE} />}
|
||||
>
|
||||
Cost — {formatCost(cost)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +133,8 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import { AiTokenCounts } from '../utils/aiTraceUtils';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface TokenUsageTooltipProps {
|
||||
tokens: AiTokenCounts;
|
||||
}
|
||||
|
||||
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
|
||||
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
|
||||
|
||||
return (
|
||||
<div className={styles.tokenTooltip}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="normal"
|
||||
className={styles.tokenTooltipTitle}
|
||||
>
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
<TokenBreakdown
|
||||
input={input}
|
||||
output={output}
|
||||
cacheRead={cacheRead}
|
||||
cacheWrite={cacheWrite}
|
||||
reasoning={reasoning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsageTooltip;
|
||||
@@ -0,0 +1,43 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.accordion {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
align-items: center !important;
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-4) !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content) {
|
||||
background: transparent;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
@include custom-scrollbar;
|
||||
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: flex;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { MouseEvent, ReactNode, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import { Collapse } from 'antd';
|
||||
|
||||
import { AIThreadMessageProps } from '../../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage } from '../../AIThreadMessage/types';
|
||||
import MessagesSection from '../../SpanMessages/MessagesSection';
|
||||
import { RawMessagesValue } from '../../SpanMessages/utils';
|
||||
import { ThreadView } from '../../TraceDetailsThread/types';
|
||||
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
|
||||
|
||||
import styles from './MessagesAccordion.module.scss';
|
||||
|
||||
const PANEL_KEY = 'messages';
|
||||
|
||||
interface MessagesAccordionProps extends Pick<
|
||||
AIThreadMessageProps,
|
||||
'isOutput' | 'canLinkToToolCall' | 'onToolLinkClick'
|
||||
> {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function MessagesAccordion({
|
||||
icon,
|
||||
title,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
isOutput,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagesAccordionProps): JSX.Element | null {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [view, setView] = useState(ThreadView.Formatted);
|
||||
const canFormat = messages.length > 0;
|
||||
const effectiveView = canFormat ? view : ThreadView.Json;
|
||||
|
||||
if (!canFormat && !raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stopToggle = (event: MouseEvent): void => event.stopPropagation();
|
||||
|
||||
return (
|
||||
<section data-testid={testId}>
|
||||
<Collapse
|
||||
className={styles.accordion}
|
||||
activeKey={isOpen ? [PANEL_KEY] : []}
|
||||
onChange={(): void => setIsOpen((prev) => !prev)}
|
||||
expandIconPosition="end"
|
||||
expandIcon={({ isActive }): JSX.Element => (
|
||||
<span className={styles.chevron} data-testid={`${testId}-toggle`}>
|
||||
{isActive ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
)}
|
||||
items={[
|
||||
{
|
||||
key: PANEL_KEY,
|
||||
label: (
|
||||
<span className={styles.title}>
|
||||
{icon}
|
||||
{title}
|
||||
</span>
|
||||
),
|
||||
extra: isOpen &&
|
||||
canFormat && (
|
||||
// The view toggle lives in the header; its clicks must not fold the panel.
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<span onClick={stopToggle}>
|
||||
<ThreadViewToggle
|
||||
value={effectiveView}
|
||||
onChange={setView}
|
||||
testId={`${testId}-view-toggle`}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<MessagesSection
|
||||
view={effectiveView}
|
||||
messages={messages}
|
||||
raw={raw}
|
||||
testId={`${testId}-body`}
|
||||
isOutput={isOutput}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
MessagesAccordion.defaultProps = {
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesAccordion;
|
||||
@@ -0,0 +1,7 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
|
||||
|
||||
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
|
||||
import { getSpanMessages, hasAnyMessages } from '../../SpanMessages/utils';
|
||||
import { ThreadSpan } from '../../TraceDetailsThread/types';
|
||||
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
|
||||
import MessagesAccordion from './MessagesAccordion';
|
||||
|
||||
import styles from './SpanAIMessages.module.scss';
|
||||
|
||||
interface SpanAIMessagesProps {
|
||||
span: ThreadSpan;
|
||||
}
|
||||
|
||||
/** Waterfall spans only carry raw attributes, so they get the JSON view alone. */
|
||||
function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const toolCallKeys = useMemo(() => buildToolCallIndex([span]), [span]);
|
||||
const { canLinkToToolCall, onToolLinkClick } = useToolCallLinks(
|
||||
rootRef,
|
||||
toolCallKeys,
|
||||
);
|
||||
|
||||
if (!hasAnyMessages(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef} data-testid="span-ai-messages">
|
||||
<MessagesAccordion
|
||||
icon={<ArrowUpRight size={14} />}
|
||||
title="Input"
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
testId="span-ai-messages-input"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
<MessagesAccordion
|
||||
icon={<ArrowDownRight size={14} />}
|
||||
title="Output"
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
isOutput
|
||||
testId="span-ai-messages-output"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanAIMessages;
|
||||
@@ -34,6 +34,15 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Inside the Overview tab there's no flex gap, so match .panelBody's 16px.
|
||||
.tabAiMessages {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Logs-tab footer. Sibling of the scrolling .panelBody inside .panel, so it's
|
||||
// a true fixed footer flush with the panel's bottom edge — always visible
|
||||
// regardless of how the body is scrolled.
|
||||
|
||||
@@ -48,6 +48,7 @@ import Events from './Events/Events';
|
||||
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
|
||||
import SpanSummary from './SpanSummary';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
@@ -281,6 +282,7 @@ function SpanDetailsContent({
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
);
|
||||
const aiMessages = <SpanAIMessages span={selectedSpan} />;
|
||||
const eventsCount = selectedSpan.events?.length || 0;
|
||||
|
||||
const spanDetailTabs: TabsItemProps[] = [
|
||||
@@ -291,6 +293,7 @@ function SpanDetailsContent({
|
||||
children: (
|
||||
<>
|
||||
{isWide && summary}
|
||||
{isWide && <div className={styles.tabAiMessages}>{aiMessages}</div>}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
@@ -369,6 +372,7 @@ function SpanDetailsContent({
|
||||
<>
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
{!isWide && aiMessages}
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
<Tabs
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link2 } from '@signozhq/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
@@ -31,6 +33,10 @@ function SpanSummary({
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
const aiUsage = useMemo(
|
||||
() => getAIUsageDetails(selectedSpan)?.usage,
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -63,6 +69,8 @@ function SpanSummary({
|
||||
: undefined
|
||||
}
|
||||
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
tokens={aiUsage && convertToAiTokens(aiUsage)}
|
||||
cost={aiUsage?.cost}
|
||||
/>
|
||||
|
||||
<div className={styles.spanInfoItem}>
|
||||
|
||||
@@ -95,4 +95,26 @@ describe('SpanSummary', () => {
|
||||
|
||||
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tokens and cost from the gen_ai usage attributes', () => {
|
||||
const span = {
|
||||
...createMockSpan(),
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 1000,
|
||||
'gen_ai.usage.output_tokens': 9,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.000009,
|
||||
},
|
||||
};
|
||||
render(<SpanSummary selectedSpan={span} />);
|
||||
|
||||
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits tokens and cost for non-AI spans', () => {
|
||||
render(<SpanSummary selectedSpan={createMockSpan()} />);
|
||||
|
||||
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/aiTraceUtils';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
<Badge
|
||||
variant="outlined"
|
||||
color={color}
|
||||
testId={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
12
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { BadgeColorType } from '@signozhq/ui/badge';
|
||||
|
||||
import { AiSpanKind } from '../utils/aiTraceUtils';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: BadgeColorType }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'success' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'warning' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'highlight-danger' },
|
||||
};
|
||||
@@ -8,16 +8,12 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
|
||||
@@ -9,28 +9,17 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 223px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
.title {
|
||||
composes: tooltipTitle from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.preview {
|
||||
// container for additional preview rows
|
||||
}
|
||||
|
||||
.previewKey {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
color: var(--l1-foreground);
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -7,6 +8,11 @@ import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { getAIUsageDetails, SpanAiDetails } from '../utils/aiTraceUtils';
|
||||
import SpanUsageBreakdown from './SpanUsageBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
import styles from './SpanHoverCard.module.scss';
|
||||
|
||||
/**
|
||||
@@ -33,6 +39,7 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -42,33 +49,60 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.name} style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
|
||||
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
|
||||
<div className={styles.row}>
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
<div className={styles.header}>
|
||||
<Typography.Text
|
||||
className={styles.title}
|
||||
size="small"
|
||||
weight="normal"
|
||||
style={{ color }}
|
||||
>
|
||||
{spanName}
|
||||
</Typography.Text>
|
||||
{ai?.usage && (
|
||||
<Typography.Text size="small" weight="normal">
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TraceTooltipSection>
|
||||
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
|
||||
<TraceTooltipRow
|
||||
label="status"
|
||||
value={hasError ? 'error' : 'ok'}
|
||||
testId="span-hover-card-status"
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="start"
|
||||
value={`${toFixed(relativeStartMs, 2)} ms`}
|
||||
testId="span-hover-card-start"
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
testId="span-hover-card-duration"
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className={styles.preview}>
|
||||
<TraceTooltipSection>
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
<TraceTooltipRow
|
||||
key={row.key}
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${row.key}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -130,6 +164,7 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getAIUsageDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
interface SpanUsageBreakdownProps {
|
||||
usage: SpanAiUsage;
|
||||
}
|
||||
|
||||
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
|
||||
const {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
reasoningTokens,
|
||||
cost,
|
||||
} = usage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TokenBreakdown
|
||||
input={inputTokens}
|
||||
output={outputTokens}
|
||||
cacheRead={cacheReadTokens}
|
||||
cacheWrite={cacheCreationTokens}
|
||||
reasoning={reasoningTokens}
|
||||
/>
|
||||
{hasValue(cost) && (
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanUsageBreakdown;
|
||||
@@ -0,0 +1,68 @@
|
||||
import AIThreadMessage, {
|
||||
AIThreadMessageProps,
|
||||
} from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage } from '../AIThreadMessage/types';
|
||||
import { ThreadView } from '../TraceDetailsThread/types';
|
||||
import RawMessages from './RawMessages';
|
||||
import { RawMessagesValue } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
type MessageProps = Pick<
|
||||
AIThreadMessageProps,
|
||||
'isOutput' | 'canLinkToToolCall' | 'onToolLinkClick'
|
||||
>;
|
||||
|
||||
interface MessagesSectionProps extends MessageProps {
|
||||
title?: string;
|
||||
view: ThreadView;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function MessagesSection({
|
||||
title,
|
||||
view,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
isOutput,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagesSectionProps): JSX.Element | null {
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
if (isFormatted ? messages.length === 0 : !raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section} data-testid={testId}>
|
||||
{title && <div className={styles.sectionTitle}>{title}</div>}
|
||||
{isFormatted ? (
|
||||
<div className={styles.messages}>
|
||||
{messages.map((message, index) => (
|
||||
<AIThreadMessage
|
||||
// Messages have no stable id and never reorder.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
message={message}
|
||||
isOutput={isOutput}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<RawMessages value={raw as RawMessagesValue} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
MessagesSection.defaultProps = {
|
||||
title: undefined,
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesSection;
|
||||
@@ -0,0 +1,21 @@
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import { RawMessagesValue } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
interface RawMessagesProps {
|
||||
value: RawMessagesValue;
|
||||
}
|
||||
|
||||
function RawMessages({ value }: RawMessagesProps): JSX.Element {
|
||||
if (typeof value === 'object') {
|
||||
return <JsonBlock data={value} isCollapsible={false} />;
|
||||
}
|
||||
return (
|
||||
<pre className={styles.raw} data-testid="span-raw-messages">
|
||||
{value}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default RawMessages;
|
||||
@@ -0,0 +1,51 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.raw {
|
||||
margin: 0;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--l1-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.missing {
|
||||
padding: var(--spacing-10) var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { AIThreadMessageProps } from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import MessagesSection from './MessagesSection';
|
||||
import { getSpanMessages, hasFormattedMessages, hasRawMessages } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
interface SpanMessagesProps extends Pick<
|
||||
AIThreadMessageProps,
|
||||
'canLinkToToolCall' | 'onToolLinkClick'
|
||||
> {
|
||||
span: ThreadSpan;
|
||||
view: ThreadView;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Input and output messages of one AI span, shared by Thread and Span Details. */
|
||||
function SpanMessages({
|
||||
span,
|
||||
view,
|
||||
className,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: SpanMessagesProps): JSX.Element {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
|
||||
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.missing, className)}
|
||||
data-testid="span-messages-empty"
|
||||
>
|
||||
This span has no input or output.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFormatted) {
|
||||
return (
|
||||
<div className={cx(styles.root, className)} data-testid="span-messages-json">
|
||||
<JsonBlock
|
||||
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
|
||||
expandDepth={Infinity}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root, className)}>
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
testId="span-messages-input"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
isOutput
|
||||
testId="span-messages-output"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SpanMessages.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default SpanMessages;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
|
||||
import { GEN_AI_KEYS } from '../../utils/aiTraceUtils';
|
||||
import SpanMessages from '../SpanMessages';
|
||||
|
||||
const makeSpan = (overrides: Partial<ThreadSpan>): ThreadSpan =>
|
||||
({ span_id: 's1', attributes: {}, ...overrides }) as unknown as ThreadSpan;
|
||||
|
||||
describe('SpanMessages', () => {
|
||||
it('shows the empty state when there are no formatted messages', () => {
|
||||
const span = makeSpan({
|
||||
attributes: { [GEN_AI_KEYS.inputMessages]: '[{"role":"user"}]' },
|
||||
} as Partial<ThreadSpan>);
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Formatted} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-empty')).toHaveTextContent(
|
||||
'This span has no input or output.',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state in JSON view when there are no raw messages', () => {
|
||||
render(<SpanMessages span={makeSpan({})} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders raw messages in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
attributes: { [GEN_AI_KEYS.outputMessages]: '[{"role":"assistant"}]' },
|
||||
} as Partial<ThreadSpan>);
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-json')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens every key in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"visible-text"}]',
|
||||
},
|
||||
} as Partial<ThreadSpan>);
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByText(/visible-text/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses large JSON behind Show more in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: JSON.stringify([
|
||||
{ role: 'user', content: 'x'.repeat(2000) },
|
||||
]),
|
||||
},
|
||||
} as Partial<ThreadSpan>);
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByTestId('ai-json-show-more')).toHaveTextContent(
|
||||
'Show more',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
|
||||
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
|
||||
import { GEN_AI_KEYS } from '../../utils/aiTraceUtils';
|
||||
import SpanMessages from '../SpanMessages';
|
||||
|
||||
const makeSpan = (overrides: Partial<ThreadSpan>): ThreadSpan =>
|
||||
({
|
||||
span_id: 'span-1',
|
||||
name: 'chat gpt-4o',
|
||||
attributes: {},
|
||||
...overrides,
|
||||
}) as unknown as ThreadSpan;
|
||||
|
||||
const LARGE_ARGUMENTS = {
|
||||
query: 'weather forecast',
|
||||
locations: Array.from({ length: 12 }, (_, index) => ({
|
||||
city: `City ${index + 1}`,
|
||||
coordinates: { lat: 10 + index, lon: 20 + index },
|
||||
units: 'metric',
|
||||
})),
|
||||
};
|
||||
|
||||
const RAW_INPUT = [
|
||||
{
|
||||
role: 'system',
|
||||
parts: [{ type: 'text', content: 'You are a weather bot.' }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', content: 'What is the weather in every city?' }],
|
||||
},
|
||||
];
|
||||
|
||||
const RAW_OUTPUT = [
|
||||
{
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_1',
|
||||
name: 'get_weather',
|
||||
arguments: LARGE_ARGUMENTS,
|
||||
},
|
||||
],
|
||||
finish_reason: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const rawAttributes = {
|
||||
[GEN_AI_KEYS.inputMessages]: JSON.stringify(RAW_INPUT),
|
||||
[GEN_AI_KEYS.outputMessages]: JSON.stringify(RAW_OUTPUT),
|
||||
};
|
||||
|
||||
const clickShowMore = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
const toggles =
|
||||
await within(canvasElement).findAllByTestId('ai-json-show-more');
|
||||
await toggles.reduce(
|
||||
(previous, toggle) => previous.then(() => userEvent.click(toggle)),
|
||||
Promise.resolve(),
|
||||
);
|
||||
};
|
||||
|
||||
// Input and output messages of one AI span, as rendered on a Thread card.
|
||||
// Each story isolates one behaviour: the empty state, role-less output,
|
||||
// empty parts, and JSON blocks (copy, Show more, initial key expansion).
|
||||
const meta = {
|
||||
title: 'Pages/Traces/Trace Details/Span Messages',
|
||||
component: SpanMessages,
|
||||
args: { view: ThreadView.Formatted, span: makeSpan({}) },
|
||||
argTypes: {
|
||||
view: { control: 'inline-radio', options: Object.values(ThreadView) },
|
||||
span: { control: false },
|
||||
},
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<div style={{ maxWidth: 720, padding: 24 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof SpanMessages>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** No formatted messages: the dashed "no input or output" box. */
|
||||
export const NoInputOrOutput: Story = {};
|
||||
|
||||
/** JSON view with no raw attributes: the same empty box. */
|
||||
export const NoInputOrOutputJson: Story = {
|
||||
args: { view: ThreadView.Json },
|
||||
};
|
||||
|
||||
/** Formatted view while only raw attributes exist: still the empty box. */
|
||||
export const RawOnlyFormatted: Story = {
|
||||
args: { span: makeSpan({ attributes: rawAttributes }) },
|
||||
};
|
||||
|
||||
/** An output message with no role keeps its Output badge. */
|
||||
export const OutputWithoutRole: Story = {
|
||||
args: {
|
||||
span: makeSpan({
|
||||
formatted_output: [
|
||||
{
|
||||
parts: [{ type: 'text', content: 'Answer from a role-less message.' }],
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** Blank text, empty generic and an unknown part each render "No content". */
|
||||
export const EmptyParts: Story = {
|
||||
args: {
|
||||
span: makeSpan({
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ type: 'text', content: ' ' },
|
||||
{ type: 'generic', content: '' },
|
||||
{ type: 'unknown_kind' } as never,
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** Large tool-call JSON: keys closed, clamped behind Show more, with copy. */
|
||||
export const LargeJsonPart: Story = {
|
||||
args: {
|
||||
span: makeSpan({
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_1',
|
||||
name: 'get_weather',
|
||||
arguments: LARGE_ARGUMENTS,
|
||||
},
|
||||
],
|
||||
finish_reason: 'tool_call',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** The large tool-call JSON after Show more. */
|
||||
export const LargeJsonPartExpanded: Story = {
|
||||
...LargeJsonPart,
|
||||
play: async ({ canvasElement }): Promise<void> => clickShowMore(canvasElement),
|
||||
};
|
||||
|
||||
/** Small JSON part: no Show more, copy still available. */
|
||||
export const SmallJsonPart: Story = {
|
||||
args: {
|
||||
span: makeSpan({
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_2',
|
||||
name: 'get_time',
|
||||
arguments: { tz: 'UTC' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** JSON tab: every key open, clamped behind Show more, with copy. */
|
||||
export const JsonView: Story = {
|
||||
args: { view: ThreadView.Json, span: makeSpan({ attributes: rawAttributes }) },
|
||||
};
|
||||
|
||||
/** The JSON tab after Show more. */
|
||||
export const JsonViewExpanded: Story = {
|
||||
...JsonView,
|
||||
play: async ({ canvasElement }): Promise<void> => clickShowMore(canvasElement),
|
||||
};
|
||||
49
frontend/src/pages/TraceDetailsV3/SpanMessages/utils.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { parseJsonObject } from '../AIThreadMessage/utils';
|
||||
import { ThreadMessage } from '../AIThreadMessage/types';
|
||||
import { ThreadSpan } from '../TraceDetailsThread/types';
|
||||
import { GEN_AI_KEYS } from '../utils/aiTraceUtils';
|
||||
|
||||
/** Parsed JSON, or the original string when it isn't JSON. */
|
||||
export type RawMessagesValue = object | string;
|
||||
|
||||
export interface SpanMessagesData {
|
||||
formattedInput: ThreadMessage[];
|
||||
formattedOutput: ThreadMessage[];
|
||||
rawInput?: RawMessagesValue;
|
||||
rawOutput?: RawMessagesValue;
|
||||
}
|
||||
|
||||
function toRawValue(value: unknown): RawMessagesValue | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return parseJsonObject(value) ?? value;
|
||||
}
|
||||
return typeof value === 'object' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function getSpanMessages(span: ThreadSpan): SpanMessagesData {
|
||||
const rawInput = toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]);
|
||||
const rawOutput = toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]);
|
||||
|
||||
// Waterfall spans and spans from before the JSON column have no formatted messages.
|
||||
return {
|
||||
formattedInput: span.formatted_input ?? [],
|
||||
formattedOutput: span.formatted_output ?? [],
|
||||
rawInput,
|
||||
rawOutput,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasFormattedMessages(data: SpanMessagesData): boolean {
|
||||
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
|
||||
}
|
||||
|
||||
export function hasRawMessages(data: SpanMessagesData): boolean {
|
||||
return !!data.rawInput || !!data.rawOutput;
|
||||
}
|
||||
|
||||
export function hasAnyMessages(data: SpanMessagesData): boolean {
|
||||
return hasFormattedMessages(data) || hasRawMessages(data);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatTokens,
|
||||
getTokenLabel,
|
||||
getTotalInputTokens,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
type TokenBreakdownProps = AiTokenCounts;
|
||||
|
||||
function TokenBreakdown({
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
reasoning,
|
||||
}: TokenBreakdownProps): JSX.Element {
|
||||
const totalInput = getTotalInputTokens({ input, cacheRead, cacheWrite });
|
||||
|
||||
return (
|
||||
<>
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Total Input', totalInput)}
|
||||
value={formatTokens(totalInput)}
|
||||
isHeading
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Read', cacheRead)}
|
||||
value={formatTokens(cacheRead)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Write', cacheWrite)}
|
||||
value={formatTokens(cacheWrite)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Input', input)}
|
||||
value={formatTokens(input)}
|
||||
isNested
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Reasoning', reasoning)}
|
||||
value={formatTokens(reasoning)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Output', output)}
|
||||
value={formatTokens(output)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label="Total Token Usage"
|
||||
value={formatTokens(totalInput + (output ?? 0))}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TokenBreakdown.defaultProps = {
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
reasoning: undefined,
|
||||
};
|
||||
|
||||
export default TokenBreakdown;
|
||||
@@ -29,6 +29,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
@@ -13,22 +13,26 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import TraceDownloadPanel from './TraceDownloadPanel';
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
|
||||
import MissingSpansBanner from './MissingSpansBanner';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
import { useTraceSummary } from './useTraceSummary';
|
||||
import TraceDetailsTabs from './TraceDetailsTabs';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
showTraceDetailsHeaderOptions?: boolean;
|
||||
}
|
||||
|
||||
@@ -60,6 +64,14 @@ function TraceDetailsHeader({
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
const { data: traceSummary } = useTraceSummary(traceID);
|
||||
const [tab] = useTraceDetailsTab();
|
||||
const isOverview = tab === TraceDetailsTab.Overview;
|
||||
|
||||
// Overview-only panels must not linger over another tab.
|
||||
useEffect(() => {
|
||||
setIsPreviewFieldsOpen(false);
|
||||
setIsAnalyticsOpen(false);
|
||||
}, [tab]);
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
@@ -93,13 +105,14 @@ function TraceDetailsHeader({
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
|
||||
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
|
||||
});
|
||||
|
||||
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
|
||||
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
|
||||
|
||||
const durationMs = traceSummary
|
||||
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
@@ -120,9 +133,10 @@ function TraceDetailsHeader({
|
||||
badgeValue={traceID}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
<TraceDetailsTabs />
|
||||
</div>
|
||||
)}
|
||||
{showTraceDetailsHeaderOptions && traceSummary && (
|
||||
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
@@ -172,28 +186,32 @@ function TraceDetailsHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showTraceDetails && (
|
||||
{(showTraceDetails || !isOverview) && (
|
||||
<div className={styles.subHeader}>
|
||||
{traceSummary ? (
|
||||
{!traceSummary ? (
|
||||
<DetailsLoader />
|
||||
) : (
|
||||
<EntityMetadataRow
|
||||
entity="trace"
|
||||
service={{
|
||||
name: traceSummary.rootServiceName,
|
||||
entryPoint: traceSummary.rootServiceEntryPoint,
|
||||
}}
|
||||
durationMs={durationMs}
|
||||
durationMs={
|
||||
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
}
|
||||
timestamp={dayjs(traceSummary.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceSummary.rootSpanStatusCode}
|
||||
tokens={traceSummary.ai?.tokens}
|
||||
cost={traceSummary.ai?.totalCost}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
|
||||
const TAB_ITEMS = [
|
||||
{
|
||||
value: TraceDetailsTab.Overview,
|
||||
label: 'Overview',
|
||||
testId: 'trace-details-tab-overview',
|
||||
},
|
||||
{
|
||||
value: TraceDetailsTab.Thread,
|
||||
label: 'Thread',
|
||||
testId: 'trace-details-tab-thread',
|
||||
},
|
||||
];
|
||||
|
||||
function TraceDetailsTabs(): JSX.Element {
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
// Clicking the active item deselects it and reports ''.
|
||||
if (value) {
|
||||
void setTab(value as TraceDetailsTab);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.tabs}>
|
||||
<ToggleGroup
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
type="single"
|
||||
value={tab}
|
||||
onChange={handleChange}
|
||||
size="sm"
|
||||
items={TAB_ITEMS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsTabs;
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
@@ -176,9 +178,19 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
hasMissingSpans: false,
|
||||
totalSpansCount: 42,
|
||||
totalErrorSpansCount: 0,
|
||||
ai: {
|
||||
tokens: {
|
||||
input: 12040,
|
||||
output: 3110,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
totalCost: 0.0421,
|
||||
},
|
||||
};
|
||||
|
||||
const mockSummary = (data?: typeof traceMetadata): void => {
|
||||
const mockSummary = (data?: SpantypesGettableTraceSummaryDTO): void => {
|
||||
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
|
||||
};
|
||||
|
||||
@@ -201,6 +213,77 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
expect(screen.getByText(duration)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders AI tokens and cost when the summary has them', () => {
|
||||
mockSummary(traceMetadata);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits AI tokens and cost when the summary has no ai field', () => {
|
||||
mockSummary({ ...traceMetadata, ai: undefined });
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AI tokens when input and output are both zero', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: { ...traceMetadata.ai.tokens, input: 0, output: 0 },
|
||||
},
|
||||
} as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero for a missing input', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: { ...traceMetadata.ai.tokens, input: undefined },
|
||||
},
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 3,110')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero AI tokens when input and output are both missing', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: {
|
||||
...traceMetadata.ai.tokens,
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
},
|
||||
},
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the cost when the summary sends null', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: { ...traceMetadata.ai, totalCost: null },
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
mockSummary(traceMetadata);
|
||||
@@ -233,3 +316,96 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
const metadataText = /Missing Span/;
|
||||
|
||||
describe('TraceDetailsHeader – tabs', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(useTraceSummary).mockReturnValue({
|
||||
data: {
|
||||
...SUMMARY,
|
||||
rootServiceEntryPoint: 'Missing Span',
|
||||
hasMissingSpans: true,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest
|
||||
.mocked(useTraceSummary)
|
||||
.mockReturnValue({ data: undefined, isLoading: false });
|
||||
});
|
||||
|
||||
// In-memory URL state, so a tab switch can't leak into the next test.
|
||||
const renderHeader = (): void => {
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
};
|
||||
|
||||
it('shows the Overview-only sections on the Overview tab', () => {
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /trace options/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps only the metadata row on the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /trace options/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /hide trace details/i }),
|
||||
);
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the Analytics panel when switching to the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^analytics$/i }));
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'true',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layoutRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -0,0 +1,498 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import NoData from '../NoData/NoData';
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
useTraceStore,
|
||||
} from '../stores/traceStore';
|
||||
import {
|
||||
SPAN_DETAILS_PANEL_MIN_WIDTH,
|
||||
SpanDetailVariant,
|
||||
} from '../SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from '../TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from '../utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsOverview.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
|
||||
interface TraceDetailsOverviewProps {
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
onHasTraceDataChange: (hasTraceData: boolean) => void;
|
||||
}
|
||||
|
||||
function TraceDetailsOverview({
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
onHasTraceDataChange,
|
||||
}: TraceDetailsOverviewProps): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
params.delete('spanId');
|
||||
safeNavigate({ search: params.toString() }, { replace: true });
|
||||
}, [safeNavigate]);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
useEffect(() => {
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
}
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, handleVariantChange] = useSpanDetailVariant();
|
||||
|
||||
const RIGHT_DOCK_MIN = SPAN_DETAILS_PANEL_MIN_WIDTH;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const hasTraceData = !showNoData && allSpans.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onHasTraceDataChange(hasTraceData);
|
||||
return (): void => onHasTraceDataChange(false);
|
||||
}, [hasTraceData, onHasTraceDataChange]);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
defaultHeight={300}
|
||||
minHeight={150}
|
||||
disabled={!isWaterfallDocked}
|
||||
>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId || ''}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
isFullDataLoaded={isFullDataLoaded}
|
||||
localUncollapsedNodes={localUncollapsedNodes}
|
||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Info
|
||||
size={16}
|
||||
color="var(--l2-foreground)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</WarningPopover>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsOverview;
|
||||
@@ -0,0 +1,40 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-20) var(--spacing-24);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 428px;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.paragraphs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--paragraph-base-500-line-height);
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0;
|
||||
font-family: var(--paragraph-base-500-font-family);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
letter-spacing: var(--paragraph-base-500-letter-spacing);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
composes: text;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ArrowRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { LLM_OBSERVABILITY_DOCS_URL } from '../constants';
|
||||
|
||||
import styles from './ThreadNoAiSpans.module.scss';
|
||||
|
||||
function ThreadNoAiSpans(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.root} data-testid="thread-empty">
|
||||
<div className={styles.message}>
|
||||
<span className={styles.emoji} aria-hidden>
|
||||
🚏
|
||||
</span>
|
||||
<div className={styles.paragraphs}>
|
||||
<p className={styles.text}>
|
||||
Looks like this trace does not have any spans from AI-related actions.
|
||||
</p>
|
||||
<p className={styles.highlight}>
|
||||
Thread view summarising your entire AI-journey history will appear here
|
||||
when the spans have the necessary datapoints.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
suffix={<ArrowRight size={12} />}
|
||||
onClick={(): void => {
|
||||
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
|
||||
}}
|
||||
testId="thread-empty-learn-more"
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadNoAiSpans;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
|
||||
import styles from './ThreadSpanList.module.scss';
|
||||
|
||||
interface ThreadPageLoaderProps {
|
||||
testId: string;
|
||||
}
|
||||
|
||||
// Mirrors a span card: a short message, then an output with a block and a chip.
|
||||
function ThreadPageLoader({ testId }: ThreadPageLoaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.pageLoader} data-testid={testId}>
|
||||
<Skeleton active title={{ width: 48 }} paragraph={{ rows: 1 }} />
|
||||
<Skeleton
|
||||
active
|
||||
title={{ width: 112 }}
|
||||
paragraph={{ rows: 3, width: ['100%', '100%', 160] }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadPageLoader;
|
||||