mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-25 09:30:31 +01:00
Compare commits
5 Commits
nv/schema-
...
trace-deta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e84ccdf91 | ||
|
|
0a5f3c3b39 | ||
|
|
38d6f35e72 | ||
|
|
db1ed748a7 | ||
|
|
9c92335548 |
@@ -2553,6 +2553,17 @@ components:
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
DashboardTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
type: boolean
|
||||
display:
|
||||
$ref: '#/components/schemas/VariableDisplay'
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesAxes:
|
||||
properties:
|
||||
isLogScale:
|
||||
@@ -2917,15 +2928,9 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesLegendMode'
|
||||
position:
|
||||
$ref: '#/components/schemas/DashboardtypesLegendPosition'
|
||||
type: object
|
||||
DashboardtypesLegendMode:
|
||||
enum:
|
||||
- list
|
||||
type: string
|
||||
DashboardtypesLegendPosition:
|
||||
enum:
|
||||
- bottom
|
||||
@@ -2976,25 +2981,15 @@ components:
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
name:
|
||||
minLength: 1
|
||||
type: string
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesVariablePlugin'
|
||||
sort:
|
||||
$ref: '#/components/schemas/DashboardtypesListVariableSpecSort'
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- display
|
||||
type: object
|
||||
DashboardtypesListVariableSpecSort:
|
||||
enum:
|
||||
- none
|
||||
- alphabetical-asc
|
||||
- alphabetical-desc
|
||||
- numerical-asc
|
||||
- numerical-desc
|
||||
- alphabetical-ci-asc
|
||||
- alphabetical-ci-desc
|
||||
type: string
|
||||
DashboardtypesListableDashboardForUserV2:
|
||||
properties:
|
||||
dashboards:
|
||||
@@ -3504,13 +3499,8 @@ components:
|
||||
DashboardtypesSpanGaps:
|
||||
properties:
|
||||
fillLessThan:
|
||||
description: The maximum gap size to connect when fillOnlyBelow is true.
|
||||
Gaps larger than this duration are left disconnected.
|
||||
type: string
|
||||
fillOnlyBelow:
|
||||
description: Controls whether lines connect across null values. When false
|
||||
(default), all gaps are connected. When true, only gaps smaller than fillLessThan
|
||||
are connected.
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesStorableDashboardData:
|
||||
@@ -3558,20 +3548,6 @@ components:
|
||||
- color
|
||||
- columnName
|
||||
type: object
|
||||
DashboardtypesTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
type: boolean
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
name:
|
||||
minLength: 1
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
@@ -3591,6 +3567,7 @@ components:
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
- label
|
||||
type: object
|
||||
DashboardtypesTimePreference:
|
||||
enum:
|
||||
@@ -3675,11 +3652,23 @@ components:
|
||||
discriminator:
|
||||
mapping:
|
||||
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
|
||||
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
|
||||
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
|
||||
type: object
|
||||
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- TextVariable
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardTextVariableSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec:
|
||||
properties:
|
||||
@@ -3693,18 +3682,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- TextVariable
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesTextVariableSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVariablePlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -7790,6 +7767,15 @@ components:
|
||||
type: object
|
||||
VariableDefaultValue:
|
||||
type: object
|
||||
VariableDisplay:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
hidden:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
ZeustypesGettableHost:
|
||||
properties:
|
||||
hosts:
|
||||
|
||||
@@ -3266,6 +3266,37 @@ export interface DashboardLinkDTO {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface VariableDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hidden?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface DashboardTextVariableSpecDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
constant?: boolean;
|
||||
display?: VariableDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAxesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -3297,9 +3328,6 @@ export interface DashboardtypesPanelFormattingDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesLegendModeDTO {
|
||||
list = 'list',
|
||||
}
|
||||
export enum DashboardtypesLegendPositionDTO {
|
||||
bottom = 'bottom',
|
||||
right = 'right',
|
||||
@@ -3319,7 +3347,6 @@ export interface DashboardtypesLegendDTO {
|
||||
* @type object,null
|
||||
*/
|
||||
customColors?: DashboardtypesLegendDTOCustomColors;
|
||||
mode?: DashboardtypesLegendModeDTO;
|
||||
position?: DashboardtypesLegendPositionDTO;
|
||||
}
|
||||
|
||||
@@ -3331,7 +3358,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
label?: string;
|
||||
label: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3996,12 +4023,10 @@ export enum DashboardtypesLineStyleDTO {
|
||||
export interface DashboardtypesSpanGapsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
|
||||
*/
|
||||
fillLessThan?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
|
||||
*/
|
||||
fillOnlyBelow?: boolean;
|
||||
}
|
||||
@@ -4633,15 +4658,6 @@ export type DashboardtypesVariablePluginDTO =
|
||||
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTO
|
||||
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTO;
|
||||
|
||||
export enum DashboardtypesListVariableSpecSortDTO {
|
||||
none = 'none',
|
||||
'alphabetical-asc' = 'alphabetical-asc',
|
||||
'alphabetical-desc' = 'alphabetical-desc',
|
||||
'numerical-asc' = 'numerical-asc',
|
||||
'numerical-desc' = 'numerical-desc',
|
||||
'alphabetical-ci-asc' = 'alphabetical-ci-asc',
|
||||
'alphabetical-ci-desc' = 'alphabetical-ci-desc',
|
||||
}
|
||||
export interface DashboardtypesListVariableSpecDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4660,14 +4676,16 @@ export interface DashboardtypesListVariableSpecDTO {
|
||||
*/
|
||||
customAllValue?: string;
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
display?: DashboardtypesDisplayDTO;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @minLength 1
|
||||
*/
|
||||
name: string;
|
||||
name?: string;
|
||||
plugin?: DashboardtypesVariablePluginDTO;
|
||||
sort?: DashboardtypesListVariableSpecSortDTO;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
sort?: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO {
|
||||
@@ -4679,38 +4697,21 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
|
||||
spec: DashboardtypesListVariableSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind {
|
||||
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
|
||||
TextVariable = 'TextVariable',
|
||||
}
|
||||
export interface DashboardtypesTextVariableSpecDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
constant?: boolean;
|
||||
display?: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @minLength 1
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO {
|
||||
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
|
||||
/**
|
||||
* @enum TextVariable
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind;
|
||||
spec: DashboardtypesTextVariableSpecDTO;
|
||||
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
|
||||
spec: DashboardTextVariableSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesVariableDTO =
|
||||
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
|
||||
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
|
||||
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
|
||||
|
||||
export interface DashboardtypesDashboardSpecDTO {
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from 'pages/TracesFunnels/FunnelContext';
|
||||
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import './AddSpanToFunnelModal.styles.scss';
|
||||
@@ -71,7 +72,9 @@ function FunnelDetailsView({
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span}
|
||||
// Dead V2 code (removed in the trace-v2 cleanup sweep); FunnelConfiguration
|
||||
// now takes SpanV3, so bridge the V2 Span here to keep the build green.
|
||||
span={span as unknown as SpanV3}
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextEnvelopeKind,
|
||||
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
DashboardtypesVariablePluginDTO,
|
||||
DashboardtypesTextVariableSpecDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
signalForApi,
|
||||
VARIABLE_SORT_DISABLED,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from './variableFormModel';
|
||||
|
||||
/** DTO envelope → flat form model (for display / editing). */
|
||||
@@ -36,7 +37,7 @@ export function dtoToFormModel(
|
||||
|
||||
// Text variable — a distinct envelope (no list plugin).
|
||||
if (dto.kind === TextEnvelopeKind.TextVariable) {
|
||||
const spec = dto.spec as DashboardtypesTextVariableSpecDTO;
|
||||
const spec = dto.spec as DashboardTextVariableSpecDTO;
|
||||
return {
|
||||
...common,
|
||||
type: 'TEXT',
|
||||
@@ -51,7 +52,7 @@ export function dtoToFormModel(
|
||||
...common,
|
||||
multiSelect: spec.allowMultiple ?? false,
|
||||
showAllOption: spec.allowAllValue ?? false,
|
||||
sort: spec.sort ?? VARIABLE_SORT_DISABLED,
|
||||
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
|
||||
defaultValue: spec.defaultValue,
|
||||
};
|
||||
const plugin = spec.plugin;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
DashboardtypesListVariableSpecSortDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { sortBy } from 'lodash-es';
|
||||
|
||||
@@ -27,19 +24,19 @@ export const DYNAMIC_SIGNAL_ALL = 'all' as const;
|
||||
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
|
||||
|
||||
/**
|
||||
* Sort order for list-variable values, keyed by the generated wire enum so the
|
||||
* form model and the DTO `sort` field share one source of truth. The friendly
|
||||
* keys (`DISABLED` / `ASC` / …) are UI-facing; the values are the Perses `Sort`
|
||||
* tokens the wire validates against.
|
||||
* Sort order for list-variable values. The wire (Perses) validates `sort`
|
||||
* against a fixed method set. There is no generated TS enum for it
|
||||
* (`DashboardtypesListOrderDTO` is the query-builder order, a different field),
|
||||
* so we mirror the Perses `Sort` tokens here.
|
||||
*/
|
||||
export const VARIABLE_SORT = {
|
||||
DISABLED: DashboardtypesListVariableSpecSortDTO.none,
|
||||
ASC: DashboardtypesListVariableSpecSortDTO['alphabetical-asc'],
|
||||
DESC: DashboardtypesListVariableSpecSortDTO['alphabetical-desc'],
|
||||
NUMERICAL_ASC: DashboardtypesListVariableSpecSortDTO['numerical-asc'],
|
||||
NUMERICAL_DESC: DashboardtypesListVariableSpecSortDTO['numerical-desc'],
|
||||
CI_ASC: DashboardtypesListVariableSpecSortDTO['alphabetical-ci-asc'],
|
||||
CI_DESC: DashboardtypesListVariableSpecSortDTO['alphabetical-ci-desc'],
|
||||
DISABLED: 'none',
|
||||
ASC: 'alphabetical-asc',
|
||||
DESC: 'alphabetical-desc',
|
||||
NUMERICAL_ASC: 'numerical-asc',
|
||||
NUMERICAL_DESC: 'numerical-desc',
|
||||
CI_ASC: 'alphabetical-ci-asc',
|
||||
CI_DESC: 'alphabetical-ci-desc',
|
||||
} as const;
|
||||
|
||||
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Redirect, useParams } from 'react-router-dom';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
// Legacy /trace-old/:id now redirects to the current /trace/:id view,
|
||||
// preserving the query string and hash.
|
||||
export default function TraceDetailOldRedirect(): JSX.Element {
|
||||
const { id } = useParams<TraceDetailV2URLProps>();
|
||||
const { id } = useParams<TraceDetailV3URLProps>();
|
||||
|
||||
return (
|
||||
<Redirect
|
||||
|
||||
90
frontend/src/pages/TraceDetailsV3/NoData/NoData.module.scss
Normal file
90
frontend/src/pages/TraceDetailsV3/NoData/NoData.module.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
.notFoundTrace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
width: 500px;
|
||||
gap: var(--spacing-12);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.notFoundImg {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.notFoundText1 {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.notFoundText2 {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.reasons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.reason {
|
||||
display: flex;
|
||||
padding: var(--spacing-6);
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-6);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
|
||||
.reasonImg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.reasonText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.noneOfAbove {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.noneText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.actionBtns {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 160px;
|
||||
}
|
||||
73
frontend/src/pages/TraceDetailsV3/NoData/NoData.tsx
Normal file
73
frontend/src/pages/TraceDetailsV3/NoData/NoData.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { LifeBuoy, RefreshCw } from '@signozhq/icons';
|
||||
|
||||
import broomUrl from '@/assets/Icons/broom.svg';
|
||||
import constructionUrl from '@/assets/Icons/construction.svg';
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
|
||||
function NoData(): JSX.Element {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
return (
|
||||
<div className={styles.notFoundTrace} data-testid="trace-no-data">
|
||||
<section className={styles.description}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.notFoundImg} />
|
||||
<Typography.Text className={styles.notFoundText1}>
|
||||
Uh-oh! We cannot show the selected trace.
|
||||
<span className={styles.notFoundText2}>
|
||||
This can happen in either of the two scenarios -
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.reasons}>
|
||||
<div className={styles.reason}>
|
||||
<img src={constructionUrl} alt="no-data" className={styles.reasonImg} />
|
||||
<Typography.Text className={styles.reasonText}>
|
||||
The trace data has not been rendered on your SigNoz server yet. You can
|
||||
wait for a bit and refresh this page if this is the case.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.reason}>
|
||||
<img src={broomUrl} alt="no-data" className={styles.reasonImg} />
|
||||
<Typography.Text className={styles.reasonText}>
|
||||
The trace has been deleted as the data has crossed it’s retention period.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.noneOfAbove}>
|
||||
<Typography.Text className={styles.noneText}>
|
||||
If you feel the issue is none of the above, please contact support.
|
||||
</Typography.Text>
|
||||
<div className={styles.actionBtns}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.actionBtn}
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={(): void => window.location.reload()}
|
||||
testId="trace-no-data-refresh-button"
|
||||
>
|
||||
Refresh this page
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.actionBtn}
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
onClick={(): void => handleContactSupport(isCloudUserVal)}
|
||||
testId="trace-no-data-contact-support-button"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -0,0 +1,145 @@
|
||||
.noEvents {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.eventsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.event {
|
||||
:global(.ant-collapse) {
|
||||
border: none;
|
||||
}
|
||||
:global(.ant-collapse-content) {
|
||||
border-top: none;
|
||||
}
|
||||
:global(.ant-collapse-item) {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
:global(.ant-collapse-content-box) {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
}
|
||||
:global(.ant-collapse-header) {
|
||||
display: flex;
|
||||
padding: var(--spacing-4) var(--spacing-3);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
:global(.ant-collapse-expand-icon) {
|
||||
padding-inline-start: 0px;
|
||||
padding-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.collapseTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.diamond {
|
||||
fill: var(--accent-primary);
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.attributeContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.attributeKey {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.timestampContainer {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
|
||||
.attributeValue {
|
||||
display: flex;
|
||||
padding: 2px var(--spacing-4);
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.timestampText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
padding: 2px var(--spacing-4);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
.attributeValue {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.fullView {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Collapse, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { Diamond } from '@signozhq/icons';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EventAttribute from './components/EventAttribute';
|
||||
import NoData from './NoData/NoData';
|
||||
|
||||
import styles from './Events.module.scss';
|
||||
|
||||
interface IEventsTableProps {
|
||||
span: SpanV3;
|
||||
startTime: number;
|
||||
isSearchVisible: boolean;
|
||||
}
|
||||
|
||||
function EventsTable(props: IEventsTableProps): JSX.Element {
|
||||
const { span, startTime, isSearchVisible } = props;
|
||||
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
|
||||
const [modalContent, setModalContent] = useState<{
|
||||
title: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
|
||||
const showAttributeModal = (title: string, content: string): void => {
|
||||
setModalContent({ title, content });
|
||||
};
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setModalContent(null);
|
||||
};
|
||||
|
||||
const events = span.events;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{events.length === 0 && (
|
||||
<div className={styles.noEvents}>
|
||||
<NoData name="events" />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.eventsContainer}>
|
||||
{isSearchVisible && events.length > 0 && (
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search for events..."
|
||||
value={fieldSearchInput}
|
||||
onChange={(e): void => setFieldSearchInput(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{events
|
||||
.filter((eve) =>
|
||||
eve.name?.toLowerCase().includes(fieldSearchInput.toLowerCase()),
|
||||
)
|
||||
.map((event) => (
|
||||
<div
|
||||
className={styles.event}
|
||||
key={`${event.name} ${JSON.stringify(event.attributeMap)}`}
|
||||
>
|
||||
<Collapse
|
||||
size="small"
|
||||
defaultActiveKey="1"
|
||||
expandIconPosition="right"
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<div className={styles.collapseTitle}>
|
||||
<Diamond size={14} className={styles.diamond} />
|
||||
<Typography.Text>{event.name}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.attributeContainer} key="timeUnixNano">
|
||||
<Typography.Text className={styles.attributeKey}>
|
||||
Start Time
|
||||
</Typography.Text>
|
||||
<div className={styles.timestampContainer}>
|
||||
<Typography.Text className={styles.attributeValue}>
|
||||
{getYAxisFormattedValue(
|
||||
`${(event.timeUnixNano || 0) / 1e6 - startTime}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.timestampText}>
|
||||
since trace start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.timestampContainer}>
|
||||
<Typography.Text className={styles.attributeValue}>
|
||||
{getYAxisFormattedValue(
|
||||
`${(event.timeUnixNano || 0) / 1e6 - span.timestamp}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.timestampText}>
|
||||
since span start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{event.attributeMap &&
|
||||
Object.keys(event.attributeMap).map((attributeKey) => (
|
||||
<EventAttribute
|
||||
key={attributeKey}
|
||||
attributeKey={attributeKey}
|
||||
attributeValue={event.attributeMap[attributeKey]}
|
||||
onExpand={showAttributeModal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Modal
|
||||
title={modalContent?.title}
|
||||
open={!!modalContent}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
width="80vw"
|
||||
centered
|
||||
>
|
||||
<pre className={styles.fullView}>{modalContent?.content}</pre>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventsTable;
|
||||
@@ -0,0 +1,20 @@
|
||||
.noData {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.noDataImg {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.noDataText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
|
||||
interface INoDataProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
function NoData(props: INoDataProps): JSX.Element {
|
||||
const { name } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.noData}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.noDataImg} />
|
||||
<Typography.Text className={styles.noDataText}>
|
||||
No {name} found for selected span
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -0,0 +1,22 @@
|
||||
.popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
.preview {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: var(--spacing-4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 0;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Fullscreen } from '@signozhq/icons';
|
||||
|
||||
import styles from '../Events.module.scss';
|
||||
|
||||
import popoverStyles from './AttributeWithExpandablePopover.module.scss';
|
||||
|
||||
interface AttributeWithExpandablePopoverProps {
|
||||
attributeKey: string;
|
||||
attributeValue: string;
|
||||
onExpand: (title: string, content: string) => void;
|
||||
}
|
||||
|
||||
function AttributeWithExpandablePopover({
|
||||
attributeKey,
|
||||
attributeValue,
|
||||
onExpand,
|
||||
}: AttributeWithExpandablePopoverProps): JSX.Element {
|
||||
const popoverContent = (
|
||||
<div className={popoverStyles.popover}>
|
||||
<pre className={popoverStyles.preview}>{attributeValue}</pre>
|
||||
<Button
|
||||
onClick={(): void => onExpand(attributeKey, attributeValue)}
|
||||
size="sm"
|
||||
className={popoverStyles.expandButton}
|
||||
prefix={<Fullscreen size={14} />}
|
||||
>
|
||||
Expand
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.attributeContainer} key={attributeKey}>
|
||||
<Tooltip title={attributeKey}>
|
||||
<Typography.Text className={styles.attributeKey} truncate={1}>
|
||||
{attributeKey}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
<div className={styles.wrapper}>
|
||||
<Popover content={popoverContent} trigger="hover" placement="topRight">
|
||||
<Typography.Text className={styles.attributeValue} truncate={1}>
|
||||
{attributeValue}
|
||||
</Typography.Text>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeWithExpandablePopover;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from '../Events.module.scss';
|
||||
|
||||
import AttributeWithExpandablePopover from './AttributeWithExpandablePopover';
|
||||
|
||||
const EXPANDABLE_ATTRIBUTE_KEYS = ['exception.stacktrace', 'exception.message'];
|
||||
const ATTRIBUTE_LENGTH_THRESHOLD = 100;
|
||||
|
||||
interface EventAttributeProps {
|
||||
attributeKey: string;
|
||||
attributeValue: string;
|
||||
onExpand: (title: string, content: string) => void;
|
||||
}
|
||||
|
||||
function EventAttribute({
|
||||
attributeKey,
|
||||
attributeValue,
|
||||
onExpand,
|
||||
}: EventAttributeProps): JSX.Element {
|
||||
const shouldExpand =
|
||||
EXPANDABLE_ATTRIBUTE_KEYS.includes(attributeKey) ||
|
||||
attributeValue.length > ATTRIBUTE_LENGTH_THRESHOLD;
|
||||
|
||||
if (shouldExpand) {
|
||||
return (
|
||||
<AttributeWithExpandablePopover
|
||||
attributeKey={attributeKey}
|
||||
attributeValue={attributeValue}
|
||||
onExpand={onExpand}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.attributeContainer} key={attributeKey}>
|
||||
<Tooltip title={attributeKey}>
|
||||
<Typography.Text className={styles.attributeKey} truncate={1}>
|
||||
{attributeKey}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<div className={styles.wrapper}>
|
||||
<Tooltip title={attributeValue}>
|
||||
<Typography.Text className={styles.attributeValue} truncate={1}>
|
||||
{attributeValue}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventAttribute;
|
||||
@@ -27,9 +27,6 @@ import {
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
@@ -68,6 +65,9 @@ import {
|
||||
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
|
||||
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
|
||||
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
|
||||
import Events from './Events/Events';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
|
||||
@@ -424,9 +424,8 @@ function SpanDetailsContent({
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
|
||||
<Events
|
||||
span={{ ...selectedSpan, event: selectedSpan.events } as any}
|
||||
span={selectedSpan}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
.spanLogs {
|
||||
margin-inline: var(--spacing-8);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spanLogsVirtuoso {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
|
||||
.spanLogsListContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.logsLoadingSkeleton {
|
||||
height: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
color: var(--l2-foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.spanLogsEmptyContent {
|
||||
height: 100%;
|
||||
border-top: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: var(--spacing-48);
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-6);
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.noDataImg {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.noDataText1 {
|
||||
color: var(--l2-foreground);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.noDataText2 {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.actionSection {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
color: var(--l2-foreground);
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
OPERATORS,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './SpanLogs.module.scss';
|
||||
|
||||
interface SpanLogsProps {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
logs: ILog[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
handleExplorerPageRedirect: () => void;
|
||||
emptyStateConfig?: EmptyLogsListConfig;
|
||||
}
|
||||
|
||||
function SpanLogs({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
handleExplorerPageRedirect,
|
||||
emptyStateConfig,
|
||||
}: SpanLogsProps): JSX.Element {
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
|
||||
// Create trace_id and span_id filters for logs explorer navigation
|
||||
const createLogsFilter = useCallback(
|
||||
(targetSpanId: string): TagFilter => {
|
||||
const traceIdKey: BaseAutocompleteData = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
};
|
||||
|
||||
const spanIdKey: BaseAutocompleteData = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'span_id',
|
||||
};
|
||||
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: targetSpanId,
|
||||
key: spanIdKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
},
|
||||
[traceId],
|
||||
);
|
||||
|
||||
// Navigate to logs explorer with trace_id and span_id filters
|
||||
const handleLogClick = useCallback(
|
||||
(log: ILog): void => {
|
||||
// Determine if this is a span log or context log
|
||||
const isSpanLog = isLogSpanRelated(log.id);
|
||||
|
||||
// Extract log's span_id (handles both spanID and span_id properties)
|
||||
const logSpanId = log.spanID || log.span_id || '';
|
||||
|
||||
// Use appropriate span ID: current span for span logs, individual log's span for context logs
|
||||
const targetSpanId = isSpanLog ? spanId : logSpanId;
|
||||
const filters = createLogsFilter(targetSpanId);
|
||||
|
||||
// Create base query
|
||||
const baseQuery = updateAllQueriesOperators(
|
||||
initialQueriesMap[DataSource.LOGS],
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
// Add appropriate filters to the query
|
||||
const updatedQuery = {
|
||||
...baseQuery,
|
||||
builder: {
|
||||
...baseQuery.builder,
|
||||
queryData: baseQuery.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
filters,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const queryParams = {
|
||||
[QueryParams.activeLogId]: `"${log.id}"`,
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
|
||||
};
|
||||
|
||||
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
|
||||
|
||||
openInNewTab(url);
|
||||
},
|
||||
[
|
||||
isLogSpanRelated,
|
||||
createLogsFilter,
|
||||
spanId,
|
||||
updateAllQueriesOperators,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
);
|
||||
|
||||
// Footer rendering for pagination
|
||||
const hasReachedEndOfLogs = false;
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: number, logToRender: ILog): JSX.Element => {
|
||||
const getIsSpanRelated = (log: ILog, currentSpanId: string): boolean => {
|
||||
if (log.spanID) {
|
||||
return log.spanID === currentSpanId;
|
||||
}
|
||||
return log.span_id === currentSpanId;
|
||||
};
|
||||
|
||||
const isSpanRelated = getIsSpanRelated(logToRender, spanId);
|
||||
|
||||
return (
|
||||
<RawLogView
|
||||
key={logToRender.id}
|
||||
data={logToRender}
|
||||
linesPerRow={1}
|
||||
fontSize={FontSize.MEDIUM}
|
||||
onLogClick={handleLogClick}
|
||||
isHighlighted={isSpanRelated}
|
||||
helpTooltip={
|
||||
isSpanRelated ? 'This log belongs to the current span' : undefined
|
||||
}
|
||||
selectedFields={[
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'body',
|
||||
},
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'timestamp',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleLogClick, spanId],
|
||||
);
|
||||
|
||||
const renderFooter = useCallback((): JSX.Element | null => {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className={styles.logsLoadingSkeleton}> Loading more logs ... </div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasReachedEndOfLogs) {
|
||||
return <div className={styles.logsLoadingSkeleton}> *** End *** </div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [isFetching, hasReachedEndOfLogs]);
|
||||
|
||||
const renderContent = useMemo(
|
||||
() => (
|
||||
<div className={styles.spanLogsListContainer}>
|
||||
<OverlayScrollbar isVirtuoso>
|
||||
<Virtuoso
|
||||
className={styles.spanLogsVirtuoso}
|
||||
key="span-logs-virtuoso"
|
||||
style={{ height: '100%' }}
|
||||
data={logs}
|
||||
totalCount={logs.length}
|
||||
itemContent={getItemContent}
|
||||
overscan={200}
|
||||
components={{
|
||||
Footer: renderFooter,
|
||||
}}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
),
|
||||
[logs, getItemContent, renderFooter],
|
||||
);
|
||||
|
||||
const renderNoLogsFound = (): JSX.Element => (
|
||||
<div className={styles.spanLogsEmptyContent}>
|
||||
<section className={styles.description}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.noDataImg} />
|
||||
<Typography.Text className={styles.noDataText1}>
|
||||
No logs found for selected span.
|
||||
<span className={styles.noDataText2}>
|
||||
View logs for the current trace.
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.actionSection}>
|
||||
<Button
|
||||
className={styles.actionBtn}
|
||||
variant="action"
|
||||
prefix={<Compass size={14} />}
|
||||
onClick={handleExplorerPageRedirect}
|
||||
size="md"
|
||||
>
|
||||
View Logs
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSpanLogsContent = (): JSX.Element | null => {
|
||||
if (isLoading || isFetching) {
|
||||
return <LogsLoading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <LogsError />;
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
if (emptyStateConfig) {
|
||||
return (
|
||||
<EmptyLogsSearch
|
||||
dataSource={DataSource.LOGS}
|
||||
panelType="LIST"
|
||||
customMessage={emptyStateConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return renderNoLogsFound();
|
||||
}
|
||||
|
||||
return renderContent;
|
||||
};
|
||||
|
||||
return <div className={styles.spanLogs}>{renderSpanLogsContent()}</div>;
|
||||
}
|
||||
SpanLogs.defaultProps = {
|
||||
emptyStateConfig: undefined,
|
||||
};
|
||||
|
||||
export default SpanLogs;
|
||||
@@ -0,0 +1,211 @@
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import SpanLogs from '../SpanLogs';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock window.open
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
});
|
||||
|
||||
// Mock Virtuoso to avoid complex virtualization
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
Virtuoso: jest.fn(({ data, itemContent }: any) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data?.map((item: any, index: number) => (
|
||||
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock RawLogView component
|
||||
jest.mock(
|
||||
'components/Logs/RawLogView',
|
||||
() =>
|
||||
function MockRawLogView({
|
||||
data,
|
||||
onLogClick,
|
||||
isHighlighted,
|
||||
helpTooltip,
|
||||
}: any): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`raw-log-${data.id}`}
|
||||
className={isHighlighted ? 'log-highlighted' : 'log-context'}
|
||||
title={helpTooltip}
|
||||
onClick={(e): void => onLogClick?.(data, e)}
|
||||
>
|
||||
<div>{data.body}</div>
|
||||
<div>{data.timestamp}</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock PreferenceContextProvider
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({ children }: any): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
default: ({ children }: any): JSX.Element => (
|
||||
<div data-testid="overlay-scrollbar">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock LogsLoading component
|
||||
jest.mock('container/LogsLoading/LogsLoading', () => ({
|
||||
LogsLoading: function MockLogsLoading(): JSX.Element {
|
||||
return <div data-testid="logs-loading">Loading logs...</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock LogsError component
|
||||
jest.mock(
|
||||
'container/LogsError/LogsError',
|
||||
() =>
|
||||
function MockLogsError(): JSX.Element {
|
||||
return <div data-testid="logs-error">Error loading logs</div>;
|
||||
},
|
||||
);
|
||||
|
||||
// Don't mock EmptyLogsSearch - test the actual component behavior
|
||||
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
|
||||
const defaultProps = {
|
||||
traceId: TEST_TRACE_ID,
|
||||
spanId: TEST_SPAN_ID,
|
||||
timeRange: {
|
||||
startTime: 1640995200000,
|
||||
endTime: 1640995260000,
|
||||
},
|
||||
logs: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLogSpanRelated: jest.fn().mockReturnValue(false),
|
||||
handleExplorerPageRedirect: jest.fn(),
|
||||
};
|
||||
|
||||
describe('SpanLogs', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockWindowOpen.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should show simple empty state when emptyStateConfig is not provided', () => {
|
||||
render(<SpanLogs {...defaultProps} />);
|
||||
|
||||
// Should show simple empty state (no emptyStateConfig provided)
|
||||
expect(
|
||||
screen.getByText('No logs found for selected span.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('View logs for the current trace.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should NOT show enhanced empty state
|
||||
expect(screen.queryByTestId('empty-logs-search')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('documentation-links')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show enhanced empty state when entire trace has no logs', () => {
|
||||
render(
|
||||
<SpanLogs
|
||||
{...defaultProps}
|
||||
emptyStateConfig={getEmptyLogsListConfig(jest.fn())}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show enhanced empty state with custom message
|
||||
expect(screen.getByText('No logs found for this trace.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This could be because :')).toBeInTheDocument();
|
||||
|
||||
// Should show description list
|
||||
expect(
|
||||
screen.getByText('Logs are not linked to Traces.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Logs are not being sent to SigNoz.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('No logs are associated with this particular trace/span.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should show documentation links
|
||||
expect(screen.getByText('RESOURCES')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sending logs to SigNoz')).toBeInTheDocument();
|
||||
expect(screen.getByText('Correlate traces and logs')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show simple empty state
|
||||
expect(
|
||||
screen.queryByText('No logs found for selected span.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleExplorerPageRedirect when Log Explorer button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockHandleExplorerPageRedirect = jest.fn();
|
||||
|
||||
render(
|
||||
<SpanLogs
|
||||
{...defaultProps}
|
||||
handleExplorerPageRedirect={mockHandleExplorerPageRedirect}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logExplorerButton = screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
});
|
||||
await user.click(logExplorerButton);
|
||||
|
||||
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Creates a query payload for fetching logs related to a specific span
|
||||
* @param start - Start time in milliseconds
|
||||
* @param end - End time in milliseconds
|
||||
* @param filter - V5 filter expression for trace_id and span_id
|
||||
* @param order - Timestamp ordering ('desc' for newest first, 'asc' for oldest first)
|
||||
* @returns Query payload for logs API
|
||||
*/
|
||||
export const getSpanLogsQueryPayload = (
|
||||
start: number,
|
||||
end: number,
|
||||
filter: Filter,
|
||||
order: 'asc' | 'desc' = 'desc',
|
||||
): GetQueryResultsProps => ({
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
query: {
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter,
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order,
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
id: uuidv4(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates tag filters for querying logs by trace_id only (for context logs)
|
||||
* @param traceId - The trace identifier
|
||||
* @returns Tag filters for the query builder
|
||||
*/
|
||||
export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
|
||||
items: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
key: {
|
||||
id: uuidv4(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
},
|
||||
op: '=',
|
||||
value: traceId,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { getSpanLogsQueryPayload, getTraceOnlyFilters } from './constants';
|
||||
|
||||
interface UseSpanContextLogsProps {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isDrawerOpen?: boolean;
|
||||
}
|
||||
|
||||
interface UseSpanContextLogsReturn {
|
||||
logs: ILog[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
spanLogIds: Set<string>;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
hasTraceIdLogs: boolean;
|
||||
}
|
||||
|
||||
const traceIdKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
};
|
||||
/**
|
||||
* Creates v5 filter expression for querying logs by trace_id and span_id (for span logs)
|
||||
*/
|
||||
const createSpanLogsFilters = (traceId: string, spanId: string): Filter => {
|
||||
const spanIdKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'span_id',
|
||||
};
|
||||
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: spanId,
|
||||
key: spanIdKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
return convertFiltersToExpression(filters);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates v5 filter expression for querying context logs with id constraints
|
||||
*/
|
||||
const createContextFilters = (
|
||||
traceId: string,
|
||||
logId: string,
|
||||
operator: 'lt' | 'gt',
|
||||
): Filter => {
|
||||
const idKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'id',
|
||||
};
|
||||
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(operator === 'lt' ? OPERATORS['<'] : OPERATORS['>']),
|
||||
value: logId,
|
||||
key: idKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
return convertFiltersToExpression(filters);
|
||||
};
|
||||
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
export const useSpanContextLogs = ({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
isDrawerOpen = true,
|
||||
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
|
||||
const [allLogs, setAllLogs] = useState<ILog[]>([]);
|
||||
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Phase 1: Fetch span-specific logs (trace_id + span_id)
|
||||
const spanFilter = useMemo(
|
||||
() => createSpanLogsFilters(traceId, spanId),
|
||||
[traceId, spanId],
|
||||
);
|
||||
const spanQueryPayload = useMemo(
|
||||
() =>
|
||||
getSpanLogsQueryPayload(timeRange.startTime, timeRange.endTime, spanFilter),
|
||||
[timeRange.startTime, timeRange.endTime, spanFilter],
|
||||
);
|
||||
|
||||
const {
|
||||
data: spanData,
|
||||
isLoading: isSpanLoading,
|
||||
isError: isSpanError,
|
||||
isFetching: isSpanFetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_LOGS,
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () => GetMetricQueryRange(spanQueryPayload, ENTITY_VERSION_V5),
|
||||
enabled: !!traceId && !!spanId,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Extract span logs and track their IDs
|
||||
const spanLogs = useMemo(() => {
|
||||
if (!spanData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
|
||||
setSpanLogIds(new Set());
|
||||
return [];
|
||||
}
|
||||
|
||||
const logs = spanData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
// Track span log IDs
|
||||
const logIds = new Set(logs.map((log: ILog) => log.id));
|
||||
setSpanLogIds(logIds);
|
||||
|
||||
return logs;
|
||||
}, [spanData]);
|
||||
|
||||
// Get first and last span logs for context queries
|
||||
const { firstSpanLog, lastSpanLog } = useMemo(() => {
|
||||
if (spanLogs.length === 0) {
|
||||
return { firstSpanLog: null, lastSpanLog: null };
|
||||
}
|
||||
|
||||
const sortedLogs = [...spanLogs].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
firstSpanLog: sortedLogs[0],
|
||||
lastSpanLog: sortedLogs[sortedLogs.length - 1],
|
||||
};
|
||||
}, [spanLogs]);
|
||||
// Phase 2: Fetch context logs before first span log
|
||||
const beforeFilter = useMemo(() => {
|
||||
if (!firstSpanLog) {
|
||||
return null;
|
||||
}
|
||||
return createContextFilters(traceId, firstSpanLog.id, 'lt');
|
||||
}, [traceId, firstSpanLog]);
|
||||
|
||||
const beforeQueryPayload = useMemo(() => {
|
||||
if (!beforeFilter) {
|
||||
return null;
|
||||
}
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
beforeFilter,
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, beforeFilter]);
|
||||
|
||||
const { data: beforeData, isFetching: isBeforeFetching } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_BEFORE_LOGS,
|
||||
traceId,
|
||||
firstSpanLog?.id,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(beforeQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: !!beforeQueryPayload && !!firstSpanLog,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Phase 3: Fetch context logs after last span log
|
||||
const afterFilter = useMemo(() => {
|
||||
if (!lastSpanLog) {
|
||||
return null;
|
||||
}
|
||||
return createContextFilters(traceId, lastSpanLog.id, 'gt');
|
||||
}, [traceId, lastSpanLog]);
|
||||
|
||||
const afterQueryPayload = useMemo(() => {
|
||||
if (!afterFilter) {
|
||||
return null;
|
||||
}
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
afterFilter,
|
||||
'asc',
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, afterFilter]);
|
||||
|
||||
const { data: afterData, isFetching: isAfterFetching } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_AFTER_LOGS,
|
||||
traceId,
|
||||
lastSpanLog?.id,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(afterQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: !!afterQueryPayload && !!lastSpanLog,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Extract context logs
|
||||
const beforeLogs = useMemo(() => {
|
||||
if (!beforeData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return beforeData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
}, [beforeData]);
|
||||
|
||||
const afterLogs = useMemo(() => {
|
||||
if (!afterData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return afterData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
}, [afterData]);
|
||||
|
||||
useEffect(() => {
|
||||
const combined = [...afterLogs.reverse(), ...spanLogs, ...beforeLogs];
|
||||
setAllLogs(combined);
|
||||
}, [beforeLogs, spanLogs, afterLogs]);
|
||||
|
||||
// Phase 4: Check for trace_id-only logs when span has no logs
|
||||
// This helps differentiate between "no logs for span" vs "no logs for trace"
|
||||
const traceOnlyFilter = useMemo(() => {
|
||||
if (spanLogs.length > 0) {
|
||||
return null;
|
||||
}
|
||||
const filters = getTraceOnlyFilters(traceId);
|
||||
return convertFiltersToExpression(filters);
|
||||
}, [traceId, spanLogs.length]);
|
||||
|
||||
const traceOnlyQueryPayload = useMemo(() => {
|
||||
if (!traceOnlyFilter) {
|
||||
return null;
|
||||
}
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
traceOnlyFilter,
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, traceOnlyFilter]);
|
||||
|
||||
const { data: traceOnlyData } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.TRACE_ONLY_LOGS,
|
||||
traceId,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
const hasTraceIdLogs = useMemo(() => {
|
||||
if (spanLogs.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return !!(
|
||||
traceOnlyData?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0
|
||||
);
|
||||
}, [spanLogs.length, traceOnlyData]);
|
||||
|
||||
// Helper function to check if a log belongs to the span
|
||||
const isLogSpanRelated = useCallback(
|
||||
(logId: string): boolean => spanLogIds.has(logId),
|
||||
[spanLogIds],
|
||||
);
|
||||
|
||||
return {
|
||||
logs: allLogs,
|
||||
isLoading: isSpanLoading && spanLogs.length === 0,
|
||||
isError: isSpanError,
|
||||
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
|
||||
spanLogIds,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
};
|
||||
};
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
@@ -81,7 +81,7 @@ function TraceDetailsHeader({
|
||||
isDataLoaded,
|
||||
traceMetadata,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
const { id: traceID } = useParams<TraceDetailV3URLProps>();
|
||||
const [showTraceDetails, setShowTraceDetails] = useState(true);
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
|
||||
@@ -72,7 +72,7 @@ function FunnelDetailsView({
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span as any}
|
||||
span={span}
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
|
||||
@@ -1,35 +1,45 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import SpanLineActionButtons from '../index';
|
||||
|
||||
// Mock the useCopySpanLink hook
|
||||
jest.mock('hooks/trace/useCopySpanLink');
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
name: 'test-span',
|
||||
serviceName: 'test-service',
|
||||
durationNano: 1000,
|
||||
const mockSpan: SpanV3 = {
|
||||
span_id: 'test-span-id',
|
||||
trace_id: 'test-trace-id',
|
||||
parent_span_id: 'test-parent-span-id',
|
||||
timestamp: 1234567890,
|
||||
rootSpanId: 'test-root-span-id',
|
||||
parentSpanId: 'test-parent-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
hasError: false,
|
||||
duration_nano: 1000,
|
||||
name: 'test-span',
|
||||
'service.name': 'test-service',
|
||||
has_error: false,
|
||||
status_message: 'test-status-message',
|
||||
status_code: 0,
|
||||
status_code_string: 'test-status-code-string',
|
||||
kind: 0,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'test-root-name',
|
||||
statusMessage: 'test-status-message',
|
||||
statusCodeString: 'test-status-code-string',
|
||||
spanKind: 'test-span-kind',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
kind_string: 'test-span-kind',
|
||||
has_children: false,
|
||||
has_sibling: false,
|
||||
sub_tree_node_count: 0,
|
||||
level: 0,
|
||||
attributes: {},
|
||||
resource: {},
|
||||
events: [],
|
||||
http_method: '',
|
||||
http_url: '',
|
||||
http_host: '',
|
||||
db_name: '',
|
||||
db_operation: '',
|
||||
external_http_method: '',
|
||||
external_http_url: '',
|
||||
response_status_code: '',
|
||||
is_remote: '',
|
||||
flags: 0,
|
||||
trace_state: '',
|
||||
};
|
||||
|
||||
describe('SpanLineActionButtons', () => {
|
||||
@@ -94,7 +104,7 @@ describe('SpanLineActionButtons', () => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
mockUrlQuery.delete('spanId');
|
||||
mockUrlQuery.set('spanId', mockSpan.spanId);
|
||||
mockUrlQuery.set('spanId', mockSpan.span_id);
|
||||
const link = `${
|
||||
window.location.origin
|
||||
}${mockPathname}?${mockUrlQuery.toString()}`;
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { Link } from '@signozhq/icons';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import styles from './SpanLineActionButtons.module.scss';
|
||||
|
||||
export interface SpanLineActionButtonsProps {
|
||||
span: Span;
|
||||
span: SpanV3;
|
||||
}
|
||||
export default function SpanLineActionButtons({
|
||||
span,
|
||||
|
||||
@@ -11,12 +11,12 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import NoData from 'pages/TraceDetailV2/NoData/NoData';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import NoData from './NoData/NoData';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
|
||||
@@ -9,7 +9,7 @@ import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/Funnel
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import AddFunnelDescriptionModal from './AddFunnelDescriptionModal';
|
||||
@@ -23,7 +23,7 @@ import './FunnelConfiguration.styles.scss';
|
||||
interface FunnelConfigurationProps {
|
||||
funnel: FunnelData;
|
||||
isTraceDetailsPage?: boolean;
|
||||
span?: Span;
|
||||
span?: SpanV3;
|
||||
triggerAutoSave?: boolean;
|
||||
showNotifications?: boolean;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import { Plus, Undo2 } from '@signozhq/icons';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import FunnelStep from './FunnelStep';
|
||||
import InterStepConfig from './InterStepConfig';
|
||||
@@ -18,7 +18,7 @@ function StepsContent({
|
||||
span,
|
||||
}: {
|
||||
isTraceDetailsPage?: boolean;
|
||||
span?: Span;
|
||||
span?: SpanV3;
|
||||
}): JSX.Element {
|
||||
const { steps, handleAddStep, handleReplaceStep } = useFunnelContext();
|
||||
const { hasEditPermission } = useAppContext();
|
||||
@@ -30,7 +30,7 @@ function StepsContent({
|
||||
|
||||
const stepWasAdded = handleAddStep();
|
||||
if (stepWasAdded) {
|
||||
handleReplaceStep(steps.length, span.serviceName, span.name);
|
||||
handleReplaceStep(steps.length, span['service.name'], span.name);
|
||||
}
|
||||
logEvent(
|
||||
'Trace Funnels: span added for a new step from trace details page',
|
||||
@@ -61,12 +61,12 @@ function StepsContent({
|
||||
className="funnel-step-wrapper__replace-button"
|
||||
icon={<Undo2 size={12} />}
|
||||
disabled={
|
||||
(step.service_name === span.serviceName &&
|
||||
(step.service_name === span['service.name'] &&
|
||||
step.span_name === span.name) ||
|
||||
!hasEditPermission
|
||||
}
|
||||
onClick={(): void =>
|
||||
handleReplaceStep(index, span.serviceName, span.name)
|
||||
handleReplaceStep(index, span['service.name'], span.name)
|
||||
}
|
||||
>
|
||||
Replace
|
||||
|
||||
@@ -38,7 +38,7 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
|
||||
FillMode: FillModeSolid,
|
||||
SpanGaps: SpanGaps{FillLessThan: valuer.MustParseTextDuration("60s")},
|
||||
},
|
||||
Legend: Legend{Position: LegendPositionBottom, Mode: LegendModeList},
|
||||
Legend: Legend{Position: LegendPositionBottom},
|
||||
},
|
||||
},
|
||||
Queries: []Query{
|
||||
|
||||
@@ -48,42 +48,7 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func (d *DashboardSpec) Validate() error {
|
||||
if err := d.validateVariables(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.validatePanels(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.validateLayouts()
|
||||
}
|
||||
|
||||
// validateVariables rejects two variables sharing the same name.
|
||||
func (d *DashboardSpec) validateVariables() error {
|
||||
seen := make(map[string]struct{}, len(d.Variables))
|
||||
for i, v := range d.Variables {
|
||||
var name string
|
||||
switch s := v.Spec.(type) {
|
||||
case *ListVariableSpec:
|
||||
name = s.Name
|
||||
case *TextVariableSpec:
|
||||
name = s.Name
|
||||
default:
|
||||
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.variables[%d].spec: unexpected variable spec type %T", i, v.Spec)
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.variables[%d]: duplicate variable name %q", i, name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardSpec) validatePanels() error {
|
||||
for key, panel := range d.Panels {
|
||||
if err := common.ValidateID(key); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "spec.panels: %s", err.Error())
|
||||
}
|
||||
if panel == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
|
||||
}
|
||||
@@ -104,13 +69,6 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
}
|
||||
|
||||
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
|
||||
compositeSubQueryTypeToPluginKind := map[qb.QueryType]QueryPluginKind{
|
||||
qb.QueryTypeBuilder: QueryKindBuilder,
|
||||
qb.QueryTypeFormula: QueryKindFormula,
|
||||
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
|
||||
qb.QueryTypePromQL: QueryKindPromQL,
|
||||
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
|
||||
}
|
||||
if !slices.Contains(allowed, plugin.Kind) {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
|
||||
"%s: query kind %q is not supported by panel kind %q", path, plugin.Kind, panelKind)
|
||||
@@ -138,35 +96,12 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLayouts rejects grid items referencing a panel that doesn't exist.
|
||||
func (d *DashboardSpec) validateLayouts() error {
|
||||
for li, layout := range d.Layouts {
|
||||
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
|
||||
if !ok {
|
||||
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
|
||||
}
|
||||
for ii, item := range grid.Items {
|
||||
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
|
||||
if item.Content == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: content reference is required", path)
|
||||
}
|
||||
key, err := panelKeyFromRef(item.Content.Path, item.Content.Ref, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := d.Panels[key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
|
||||
}
|
||||
}
|
||||
var (
|
||||
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
|
||||
qb.QueryTypeBuilder: QueryKindBuilder,
|
||||
qb.QueryTypeFormula: QueryKindFormula,
|
||||
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
|
||||
qb.QueryTypePromQL: QueryKindPromQL,
|
||||
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// panelKeyFromRef extracts <key> from a "#/spec/panels/<key>" content ref.
|
||||
func panelKeyFromRef(refPath []string, ref string, path string) (string, error) {
|
||||
if len(refPath) != 3 || refPath[0] != "spec" || refPath[1] != "panels" {
|
||||
return "", errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: %q must reference a panel as \"#/spec/panels/<key>\"", path, ref)
|
||||
}
|
||||
return refPath[2], nil
|
||||
}
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdatableDashboardV
|
||||
}
|
||||
patched, err := p.patch.ApplyWithOptions(raw, &jsonpatch.ApplyOptions{AllowMissingPathOnRemove: true, EnsurePathExistsOnAdd: true})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard").WithAdditional(err.Error())
|
||||
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard")
|
||||
}
|
||||
out := &UpdatableDashboardV2{}
|
||||
if err := json.Unmarshal(patched, out); err != nil {
|
||||
|
||||
@@ -405,7 +405,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{"op": "replace", "path": "/spec/display/name", "value": "Multi-step"},
|
||||
{"op": "remove", "path": "/spec/panels/p2"},
|
||||
{"op": "remove", "path": "/spec/layouts/0/spec/items/1"},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -112,174 +112,6 @@ func TestValidateOnlyVariables(t *testing.T) {
|
||||
require.NoError(t, err, "expected valid")
|
||||
}
|
||||
|
||||
func TestInvalidateDuplicateVariableNames(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [
|
||||
{
|
||||
"kind": "TextVariable",
|
||||
"spec": {"name": "env", "value": "prod"}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "env",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for duplicate variable name")
|
||||
require.Contains(t, err.Error(), `duplicate variable name "env"`)
|
||||
}
|
||||
|
||||
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
|
||||
listVarWithName := func(name string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "` + name + `",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
for _, name := range []string{"my var", "cost$", "bad!", "a/b"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName(name))
|
||||
require.Error(t, err, "expected error for invalid variable name %q", name)
|
||||
require.Contains(t, err.Error(), "is not a correct name")
|
||||
})
|
||||
}
|
||||
for _, name := range []string{"service", "my_var", "MY_VAR", "MixedCase9", "with-hyphen", "with.dot"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName(name))
|
||||
require.NoError(t, err, "expected valid variable name %q", name)
|
||||
})
|
||||
}
|
||||
t.Run("digits only", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName("123"))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cannot contain only digits")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvalidatePanelKey(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"bad key!": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for invalid panel key")
|
||||
require.Contains(t, err.Error(), "is not a correct name")
|
||||
}
|
||||
|
||||
func TestInvalidateListVariableCrossFields(t *testing.T) {
|
||||
listVar := func(specFields string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "service",
|
||||
` + specFields + `
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
|
||||
t.Run("customAllValue without allowAllValue", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "customAllValue": "*",`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "customAllValue cannot be set")
|
||||
})
|
||||
|
||||
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "allowMultiple")
|
||||
})
|
||||
|
||||
t.Run("single-element list default without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["only"],`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "allowMultiple")
|
||||
})
|
||||
|
||||
t.Run("valid sort is accepted", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"sort": "alphabetical-asc",`))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("unknown sort is rejected", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"sort": "bogus",`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown sort")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvalidateEmptyVariableName(t *testing.T) {
|
||||
cases := map[string][]byte{
|
||||
"text variable": []byte(`{
|
||||
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
|
||||
"layouts": []
|
||||
}`),
|
||||
"list variable": []byte(`{
|
||||
"variables": [{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}
|
||||
}
|
||||
}],
|
||||
"layouts": []
|
||||
}`),
|
||||
}
|
||||
for name, data := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for empty variable name")
|
||||
require.Contains(t, err.Error(), "name cannot be empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -438,65 +270,6 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
|
||||
}
|
||||
|
||||
func TestInvalidateLayoutPanelReferences(t *testing.T) {
|
||||
validPanels := `"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}`
|
||||
layout := func(items string) []byte {
|
||||
return []byte(`{` + validPanels + `, "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantContain string
|
||||
}{
|
||||
{
|
||||
name: "reference to unknown panel",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/ghost"}}`),
|
||||
wantContain: `references unknown panel "ghost"`,
|
||||
},
|
||||
{
|
||||
name: "reference not pointing at a panel",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/variables/p1"}}`),
|
||||
wantContain: "must reference a panel",
|
||||
},
|
||||
{
|
||||
name: "reference missing spec prefix",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/panels/p1"}}`),
|
||||
wantContain: "must reference a panel",
|
||||
},
|
||||
{
|
||||
name: "valid reference",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}`),
|
||||
wantContain: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(tt.data)
|
||||
if tt.wantContain == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantContain)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -796,24 +569,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}`,
|
||||
wantContain: "legend position",
|
||||
},
|
||||
{
|
||||
name: "bad legend mode",
|
||||
data: `{
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BarChartPanel",
|
||||
"spec": {"legend": {"mode": "grid"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "legend mode",
|
||||
},
|
||||
{
|
||||
name: "bad threshold format",
|
||||
data: `{
|
||||
@@ -879,39 +634,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Label on ThresholdWithLabel is optional — the backend never reads it, so a
|
||||
// threshold with an omitted or empty label must validate cleanly.
|
||||
func TestThresholdLabelOptional(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
threshold string
|
||||
}{
|
||||
{name: "label omitted", threshold: `{"value": 100, "color": "Red"}`},
|
||||
{name: "label empty", threshold: `{"value": 100, "color": "Red", "label": ""}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"thresholds": [` + tt.threshold + `]}},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "threshold without a label should validate")
|
||||
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
require.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidatePanelWithoutQueries(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
@@ -1027,6 +749,11 @@ func TestValidateRequiredFields(t *testing.T) {
|
||||
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "label": "high", "color": ""}]}`),
|
||||
wantContain: "Color",
|
||||
},
|
||||
{
|
||||
name: "ThresholdWithLabel missing label",
|
||||
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "color": "Red", "label": ""}]}`),
|
||||
wantContain: "Label",
|
||||
},
|
||||
{
|
||||
name: "ComparisonThreshold missing value",
|
||||
data: wrapPanel("signoz/NumberPanel", `{"thresholds": [{"operator": "above", "format": "text", "color": "Red"}]}`),
|
||||
@@ -1084,11 +811,10 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
require.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
require.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
|
||||
require.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
|
||||
require.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
|
||||
require.Equal(t, "solid", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default solid")
|
||||
require.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
|
||||
require.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
require.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
require.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
|
||||
|
||||
// Re-marshal the full dashboard (what we'd store in DB / return in API response)
|
||||
// and verify the output contains the default values.
|
||||
@@ -1099,10 +825,9 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
"decimalPrecision": `"2"`,
|
||||
"lineInterpolation": `"spline"`,
|
||||
"lineStyle": `"solid"`,
|
||||
"fillMode": `"none"`,
|
||||
"fillMode": `"solid"`,
|
||||
"timePreference": `"global_time"`,
|
||||
"position": `"bottom"`,
|
||||
"mode": `"list"`,
|
||||
} {
|
||||
assert.Contains(t, outputStr, `"`+field+`":`+want, "expected stored/response JSON to contain %s:%s", field, want)
|
||||
}
|
||||
@@ -1205,7 +930,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
assert.Equal(t, "2", tsSpec.Formatting.DecimalPrecision.ValueOrDefault())
|
||||
assert.Equal(t, "spline", tsSpec.ChartAppearance.LineInterpolation.ValueOrDefault())
|
||||
assert.Equal(t, "solid", tsSpec.ChartAppearance.LineStyle.ValueOrDefault())
|
||||
assert.Equal(t, "none", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
|
||||
assert.Equal(t, "solid", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
|
||||
assert.Equal(t, "global_time", tsSpec.Visualization.TimePreference.ValueOrDefault())
|
||||
assert.Equal(t, "bottom", tsSpec.Legend.Position.ValueOrDefault())
|
||||
numSpec := d.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
|
||||
@@ -1225,7 +950,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
assert.Equal(t, "2", tsLoaded.Formatting.DecimalPrecision.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "spline", tsLoaded.ChartAppearance.LineInterpolation.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "solid", tsLoaded.ChartAppearance.LineStyle.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "none", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "solid", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "global_time", tsLoaded.Visualization.TimePreference.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "bottom", tsLoaded.Legend.Position.ValueOrDefault(), "after load")
|
||||
numLoaded := loaded.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
|
||||
@@ -1241,7 +966,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
"decimalPrecision": `"2"`,
|
||||
"lineInterpolation": `"spline"`,
|
||||
"lineStyle": `"solid"`,
|
||||
"fillMode": `"none"`,
|
||||
"fillMode": `"solid"`,
|
||||
"timePreference": `"global_time"`,
|
||||
"position": `"bottom"`,
|
||||
"format": `"text"`,
|
||||
|
||||
@@ -30,7 +30,6 @@ func TestDashboardSpecMatchesPerses(t *testing.T) {
|
||||
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[datasource.Spec]()},
|
||||
{"Variable", typeOf[Variable](), typeOf[dashboard.Variable]()},
|
||||
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
|
||||
{"TextVariableSpec", typeOf[TextVariableSpec](), typeOf[dashboard.TextVariableSpec]()},
|
||||
{"Layout", typeOf[Layout](), typeOf[dashboard.Layout]()},
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func (p *PanelPlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = PanelPluginKind(kind)
|
||||
p.Spec = *spec
|
||||
p.Spec = spec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = QueryPluginKind(kind)
|
||||
p.Spec = *spec
|
||||
p.Spec = spec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (p *VariablePlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = VariablePluginKind(kind)
|
||||
p.Spec = *spec
|
||||
p.Spec = spec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = DatasourcePluginKind(kind)
|
||||
p.Spec = *spec
|
||||
p.Spec = spec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -297,7 +297,8 @@ func extractKindAndSpec(data []byte) (string, []byte, error) {
|
||||
return head.Kind, head.Spec, nil
|
||||
}
|
||||
|
||||
func decodeSpec[T any](specJSON []byte, target T, kind string) (*T, error) {
|
||||
// decodeSpec strict-decodes a spec JSON into target and runs struct-tag validation (go-playground/validator).
|
||||
func decodeSpec(specJSON []byte, target any, kind string) (any, error) {
|
||||
if len(specJSON) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "kind %q: spec is required", kind)
|
||||
}
|
||||
@@ -309,12 +310,7 @@ func decodeSpec[T any](specJSON []byte, target T, kind string) (*T, error) {
|
||||
if err := validator.New().Struct(target); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: spec failed validation", kind)
|
||||
}
|
||||
if v, ok := any(target).(interface{ validate() error }); ok {
|
||||
if err := v.validate(); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: %s", kind, err.Error())
|
||||
}
|
||||
}
|
||||
return &target, nil
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// signozDiscriminatorKey is the extension key that signoz.attachDiscriminators
|
||||
|
||||
@@ -4,11 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/common"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/perses/spec/go/dashboard/variable"
|
||||
@@ -86,7 +84,7 @@ type QuerySpec struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// Variable is the list/text sum type. Spec is set to *ListVariableSpec or
|
||||
// *TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
|
||||
// *dashboard.TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
|
||||
// discriminated oneOf (see JSONSchemaOneOf).
|
||||
type Variable struct {
|
||||
Kind variable.Kind `json:"kind" required:"true"`
|
||||
@@ -96,7 +94,7 @@ type Variable struct {
|
||||
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(variable.KindList): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec"),
|
||||
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec"),
|
||||
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,14 +110,14 @@ func (v *Variable) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
v.Kind = variable.KindList
|
||||
v.Spec = *spec
|
||||
v.Spec = spec
|
||||
case string(variable.KindText):
|
||||
spec, err := decodeSpec(specJSON, new(TextVariableSpec), kind)
|
||||
spec, err := decodeSpec(specJSON, new(dashboard.TextVariableSpec), kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.Kind = variable.KindText
|
||||
v.Spec = *spec
|
||||
v.Spec = spec
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown variable kind %q; allowed values: %s", kind, allowedValuesForKind([]variable.Kind{variable.KindList, variable.KindText}))
|
||||
}
|
||||
@@ -129,7 +127,7 @@ func (v *Variable) UnmarshalJSON(data []byte) error {
|
||||
func (Variable) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
VariableEnvelope[ListVariableSpec]{Kind: string(variable.KindList)},
|
||||
VariableEnvelope[TextVariableSpec]{Kind: string(variable.KindText)},
|
||||
VariableEnvelope[dashboard.TextVariableSpec]{Kind: string(variable.KindText)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,106 +143,15 @@ func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
|
||||
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
|
||||
type ListVariableSpec struct {
|
||||
Display *Display `json:"display,omitempty"`
|
||||
Display Display `json:"display" required:"true"`
|
||||
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
|
||||
AllowAllValue bool `json:"allowAllValue"`
|
||||
AllowMultiple bool `json:"allowMultiple"`
|
||||
CustomAllValue string `json:"customAllValue,omitempty"`
|
||||
CapturingRegexp string `json:"capturingRegexp,omitempty"`
|
||||
Sort ListVariableSpecSort `json:"sort,omitzero"`
|
||||
Sort *variable.Sort `json:"sort,omitempty"`
|
||||
Plugin VariablePlugin `json:"plugin"`
|
||||
Name string `json:"name" required:"true" minLength:"1"`
|
||||
}
|
||||
|
||||
// validate mirrors perses ListVariableSpec validation (plus the digits-only name
|
||||
// check perses only applies to text variables); run by decodeSpec on unmarshal.
|
||||
func (s *ListVariableSpec) validate() error {
|
||||
if err := common.ValidateID(s.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := strconv.Atoi(s.Name); err == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
|
||||
}
|
||||
if s.CustomAllValue != "" && !s.AllowAllValue {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "customAllValue cannot be set if allowAllValue is not set to true")
|
||||
}
|
||||
if s.DefaultValue != nil && len(s.DefaultValue.SliceValues) > 0 && !s.AllowMultiple {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "defaultValue cannot be a list if allowMultiple is not set to true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListVariableSpecSort is the value-list sort method, mirrored from Perses as a
|
||||
// stable enum so the allowed values surface in the generated OpenAPI schema.
|
||||
type ListVariableSpecSort struct{ valuer.String }
|
||||
|
||||
var (
|
||||
SortNone = ListVariableSpecSort{valuer.NewString("none")}
|
||||
SortAlphabeticalAsc = ListVariableSpecSort{valuer.NewString("alphabetical-asc")}
|
||||
SortAlphabeticalDesc = ListVariableSpecSort{valuer.NewString("alphabetical-desc")}
|
||||
SortNumericalAsc = ListVariableSpecSort{valuer.NewString("numerical-asc")}
|
||||
SortNumericalDesc = ListVariableSpecSort{valuer.NewString("numerical-desc")}
|
||||
SortAlphabeticalCaseInsensitiveAsc = ListVariableSpecSort{valuer.NewString("alphabetical-ci-asc")}
|
||||
SortAlphabeticalCaseInsensitiveDesc = ListVariableSpecSort{valuer.NewString("alphabetical-ci-desc")}
|
||||
)
|
||||
|
||||
func (ListVariableSpecSort) Enum() []any {
|
||||
return []any{
|
||||
SortNone,
|
||||
SortAlphabeticalAsc,
|
||||
SortAlphabeticalDesc,
|
||||
SortNumericalAsc,
|
||||
SortNumericalDesc,
|
||||
SortAlphabeticalCaseInsensitiveAsc,
|
||||
SortAlphabeticalCaseInsensitiveDesc,
|
||||
}
|
||||
}
|
||||
|
||||
func (s ListVariableSpecSort) IsValid() bool {
|
||||
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
|
||||
}
|
||||
|
||||
// UnmarshalJSON validates against the enum on decode (valuer.String alone
|
||||
// accepts any string). An empty value is allowed and means "no sort", matching
|
||||
// Perses.
|
||||
func (s *ListVariableSpecSort) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid sort: must be a string, one of `none`, `alphabetical-asc`, `alphabetical-desc`, `numerical-asc`, `numerical-desc`, `alphabetical-ci-asc`, or `alphabetical-ci-desc`")
|
||||
}
|
||||
if v == "" {
|
||||
*s = ListVariableSpecSort{}
|
||||
return nil
|
||||
}
|
||||
sort := ListVariableSpecSort{valuer.NewString(v)}
|
||||
if !sort.IsValid() {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown sort %q: must be `none`, `alphabetical-asc`, `alphabetical-desc`, `numerical-asc`, `numerical-desc`, `alphabetical-ci-asc`, or `alphabetical-ci-desc`", v)
|
||||
}
|
||||
*s = sort
|
||||
return nil
|
||||
}
|
||||
|
||||
// TextVariableSpec replicates dashboard.TextVariableSpec so name can carry the
|
||||
// required/non-empty schema tags perses leaves off.
|
||||
type TextVariableSpec struct {
|
||||
Display *Display `json:"display,omitempty"`
|
||||
Value string `json:"value"`
|
||||
Constant bool `json:"constant,omitempty"`
|
||||
Name string `json:"name" required:"true" minLength:"1"`
|
||||
}
|
||||
|
||||
// validate mirrors perses TextVariableSpec validation; run by decodeSpec on unmarshal.
|
||||
func (s *TextVariableSpec) validate() error {
|
||||
if err := common.ValidateID(s.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := strconv.Atoi(s.Name); err == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
|
||||
}
|
||||
if s.Value == "" && s.Constant {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "value for a constant text variable cannot be empty")
|
||||
}
|
||||
return nil
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -287,7 +194,7 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
l.Kind = dashboard.LayoutKind(kind)
|
||||
l.Spec = *spec
|
||||
l.Spec = spec
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,6 @@ type TableFormatting struct {
|
||||
|
||||
type Legend struct {
|
||||
Position LegendPosition `json:"position"`
|
||||
Mode LegendMode `json:"mode"`
|
||||
CustomColors map[string]string `json:"customColors"`
|
||||
}
|
||||
|
||||
@@ -249,7 +248,7 @@ type ThresholdWithLabel struct {
|
||||
Value float64 `json:"value" validate:"required" required:"true"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required" required:"true"`
|
||||
Label string `json:"label"`
|
||||
Label string `json:"label" validate:"required" required:"true"`
|
||||
}
|
||||
|
||||
type ComparisonThreshold struct {
|
||||
@@ -359,47 +358,6 @@ func (l *LegendPosition) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
type LegendMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
LegendModeList = LegendMode{valuer.NewString("list")} // default
|
||||
LegendModeTable = LegendMode{valuer.NewString("table")}
|
||||
)
|
||||
|
||||
func (LegendMode) Enum() []any {
|
||||
return []any{LegendModeList} // others are not supported in UI yet
|
||||
}
|
||||
|
||||
func (m LegendMode) ValueOrDefault() string {
|
||||
if m.IsZero() {
|
||||
return LegendModeList.StringValue()
|
||||
}
|
||||
return m.StringValue()
|
||||
}
|
||||
|
||||
func (m LegendMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (m *LegendMode) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid legend mode: must be a string, one of `list` or `table`")
|
||||
}
|
||||
if v == "" {
|
||||
*m = LegendModeList
|
||||
return nil
|
||||
}
|
||||
lm := LegendMode{valuer.NewString(v)}
|
||||
switch lm {
|
||||
case LegendModeList, LegendModeTable:
|
||||
*m = lm
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid legend mode %q: must be `list` or `table`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type ThresholdFormat struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -576,9 +534,9 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
|
||||
type FillMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
FillModeSolid = FillMode{valuer.NewString("solid")}
|
||||
FillModeSolid = FillMode{valuer.NewString("solid")} // default
|
||||
FillModeGradient = FillMode{valuer.NewString("gradient")}
|
||||
FillModeNone = FillMode{valuer.NewString("none")} // default
|
||||
FillModeNone = FillMode{valuer.NewString("none")}
|
||||
)
|
||||
|
||||
func (FillMode) Enum() []any {
|
||||
@@ -587,7 +545,7 @@ func (FillMode) Enum() []any {
|
||||
|
||||
func (fm FillMode) ValueOrDefault() string {
|
||||
if fm.IsZero() {
|
||||
return FillModeNone.StringValue()
|
||||
return FillModeSolid.StringValue()
|
||||
}
|
||||
return fm.StringValue()
|
||||
}
|
||||
@@ -602,7 +560,7 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
|
||||
}
|
||||
if v == "" {
|
||||
*fm = FillModeNone
|
||||
*fm = FillModeSolid
|
||||
return nil
|
||||
}
|
||||
val := FillMode{valuer.NewString(v)}
|
||||
@@ -615,9 +573,12 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// SpanGaps controls whether lines connect across null values.
|
||||
// When FillOnlyBelow is false (default), all gaps are connected.
|
||||
// When FillOnlyBelow is true, only gaps smaller than FillLessThan are connected.
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan valuer.TextDuration `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow"`
|
||||
FillLessThan valuer.TextDuration `json:"fillLessThan"`
|
||||
}
|
||||
|
||||
type PrecisionOption struct{ valuer.String }
|
||||
|
||||
Reference in New Issue
Block a user