Compare commits

..

23 Commits

Author SHA1 Message Date
Naman Verma
59c9ecf444 fix: remove unused import 2026-06-25 02:42:15 +05:30
Naman Verma
2c1e1955cc Merge branch 'main' into nv/schema-changes 2026-06-25 02:40:42 +05:30
Naman Verma
95b7331e42 Merge branch 'main' into nv/schema-changes 2026-06-18 21:09:40 +05:30
Naman Verma
3531ed86f1 fix: reject single-element list default when allowMultiple is false in list variables 2026-06-18 21:08:36 +05:30
Naman Verma
805543cd0d Merge branch 'main' into nv/schema-changes 2026-06-18 20:54:49 +05:30
Naman Verma
9efa0f757d fix: add back enum values 2026-06-18 20:54:16 +05:30
Ashwin Bhatkal
01897f7869 chore: fix variable sort type errors 2026-06-18 19:50:30 +05:30
Naman Verma
d1248ab5a3 chore: remove unsupported enum values (causing errors right now) 2026-06-18 18:00:56 +05:30
Naman Verma
ddf2f3ec53 chore: replicate variable.sort into signoz 2026-06-18 17:39:06 +05:30
Naman Verma
ad4e5b8b89 Merge branch 'main' into nv/schema-changes 2026-06-18 15:40:34 +05:30
Naman Verma
8be973ac14 Merge branch 'main' into nv/schema-changes 2026-06-17 22:00:28 +05:30
Naman Verma
f2422c1fdd fix: replicate text variable spec in signoz to make name required 2026-06-17 22:00:11 +05:30
Naman Verma
6334f26e7d fix: add validations to list variable that text variable has 2026-06-17 20:51:54 +05:30
Naman Verma
563bd2b004 fix: add additional error info on patch application error 2026-06-17 17:42:23 +05:30
Naman Verma
477be8073d fix: reject dashbaords that have vars with the same name 2026-06-17 15:42:22 +05:30
Naman Verma
4e36b9a96a test: add test for missing spec prefix in layout 2026-06-17 14:11:40 +05:30
Naman Verma
7c59e379bd chore: extract out validate panels method 2026-06-17 13:42:29 +05:30
Naman Verma
eec3472e08 fix: check that panels referred in layouts exist 2026-06-17 13:39:33 +05:30
Naman Verma
b0a065591f Merge branch 'main' into nv/schema-changes 2026-06-17 07:29:11 +05:30
Naman Verma
c0e0ebab4a Merge branch 'main' into nv/schema-changes 2026-06-16 20:20:28 +05:30
Naman Verma
9e8464f3eb Merge branch 'main' into nv/schema-changes 2026-06-16 11:40:23 +05:30
Naman Verma
845c88ec45 Merge branch 'main' into nv/schema-changes 2026-06-15 16:55:36 +05:30
Naman Verma
9b53561c31 fix: change schema properties based on UI integration review 2026-06-15 15:37:29 +05:30
37 changed files with 680 additions and 1834 deletions

View File

@@ -2553,17 +2553,6 @@ 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:
@@ -2928,9 +2917,15 @@ 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
@@ -2981,15 +2976,25 @@ components:
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
minLength: 1
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesVariablePlugin'
sort:
nullable: true
type: string
$ref: '#/components/schemas/DashboardtypesListVariableSpecSort'
required:
- display
- name
type: object
DashboardtypesListVariableSpecSort:
enum:
- none
- alphabetical-asc
- alphabetical-desc
- numerical-asc
- numerical-desc
- alphabetical-ci-asc
- alphabetical-ci-desc
type: string
DashboardtypesListableDashboardForUserV2:
properties:
dashboards:
@@ -3499,8 +3504,13 @@ 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:
@@ -3548,6 +3558,20 @@ 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
@@ -3567,7 +3591,6 @@ components:
required:
- value
- color
- label
type: object
DashboardtypesTimePreference:
enum:
@@ -3652,23 +3675,11 @@ components:
discriminator:
mapping:
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/DashboardTextVariableSpec'
required:
- kind
- spec
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec:
properties:
@@ -3682,6 +3693,18 @@ 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:
@@ -7767,15 +7790,6 @@ components:
type: object
VariableDefaultValue:
type: object
VariableDisplay:
properties:
description:
type: string
hidden:
type: boolean
name:
type: string
type: object
ZeustypesGettableHost:
properties:
hosts:

View File

@@ -3266,37 +3266,6 @@ 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
@@ -3328,6 +3297,9 @@ export interface DashboardtypesPanelFormattingDTO {
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
@@ -3347,6 +3319,7 @@ export interface DashboardtypesLegendDTO {
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
@@ -3358,7 +3331,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
label: string;
label?: string;
/**
* @type string
*/
@@ -4023,10 +3996,12 @@ 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;
}
@@ -4658,6 +4633,15 @@ 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
@@ -4676,16 +4660,14 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display: DashboardtypesDisplayDTO;
display?: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name?: string;
name: string;
plugin?: DashboardtypesVariablePluginDTO;
/**
* @type string,null
*/
sort?: string | null;
sort?: DashboardtypesListVariableSpecSortDTO;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO {
@@ -4697,21 +4679,38 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
spec: DashboardtypesListVariableSpecDTO;
}
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind {
TextVariable = 'TextVariable',
}
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
export interface DashboardtypesTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display?: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name: string;
/**
* @type string
*/
value?: string;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO {
/**
* @enum TextVariable
* @type string
*/
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
spec: DashboardTextVariableSpecDTO;
kind: DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind;
spec: DashboardtypesTextVariableSpecDTO;
}
export type DashboardtypesVariableDTO =
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
export interface DashboardtypesDashboardSpecDTO {
/**

View File

@@ -20,7 +20,6 @@ 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';
@@ -72,9 +71,7 @@ function FunnelDetailsView({
<FunnelConfiguration
funnel={funnel}
isTraceDetailsPage
// 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}
span={span}
triggerAutoSave={triggerAutoSave}
showNotifications={showNotifications}
/>

View File

@@ -1,5 +1,5 @@
import {
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
@@ -9,7 +9,7 @@ import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardTextVariableSpecDTO,
DashboardtypesTextVariableSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
@@ -19,7 +19,6 @@ import {
signalForApi,
VARIABLE_SORT_DISABLED,
type VariableFormModel,
type VariableSort,
} from './variableFormModel';
/** DTO envelope → flat form model (for display / editing). */
@@ -37,7 +36,7 @@ export function dtoToFormModel(
// Text variable — a distinct envelope (no list plugin).
if (dto.kind === TextEnvelopeKind.TextVariable) {
const spec = dto.spec as DashboardTextVariableSpecDTO;
const spec = dto.spec as DashboardtypesTextVariableSpecDTO;
return {
...common,
type: 'TEXT',
@@ -52,7 +51,7 @@ export function dtoToFormModel(
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
sort: spec.sort ?? VARIABLE_SORT_DISABLED,
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;

View File

@@ -1,4 +1,7 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesListVariableSpecSortDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import { sortBy } from 'lodash-es';
@@ -24,19 +27,19 @@ export const DYNAMIC_SIGNAL_ALL = 'all' as const;
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
/**
* 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.
* 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.
*/
export const VARIABLE_SORT = {
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',
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'],
} as const;
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];

View File

@@ -1,10 +1,10 @@
import { Redirect, useParams } from 'react-router-dom';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
// 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<TraceDetailV3URLProps>();
const { id } = useParams<TraceDetailV2URLProps>();
return (
<Redirect

View File

@@ -1,90 +0,0 @@
.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;
}

View File

@@ -1,73 +0,0 @@
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 its 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;

View File

@@ -1,145 +0,0 @@
.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;
}

View File

@@ -1,136 +0,0 @@
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;

View File

@@ -1,20 +0,0 @@
.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;
}

View File

@@ -1,24 +0,0 @@
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;

View File

@@ -1,22 +0,0 @@
.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;
}

View File

@@ -1,54 +0,0 @@
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;

View File

@@ -1,54 +0,0 @@
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;

View File

@@ -27,6 +27,9 @@ 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,
@@ -65,9 +68,6 @@ 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,8 +424,9 @@ function SpanDetailsContent({
/>
</TabsContent>
<TabsContent value="events">
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
<Events
span={selectedSpan}
span={{ ...selectedSpan, event: selectedSpan.events } as any}
startTime={traceStartTime || 0}
isSearchVisible
/>

View File

@@ -1,78 +0,0 @@
.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;
}

View File

@@ -1,293 +0,0 @@
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;

View File

@@ -1,211 +0,0 @@
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);
});
});

View File

@@ -1,93 +0,0 @@
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',
});

View File

@@ -1,342 +0,0 @@
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,
};
};

View File

@@ -23,7 +23,7 @@ import {
Timer,
} from '@signozhq/icons';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
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<TraceDetailV3URLProps>();
const { id: traceID } = useParams<TraceDetailV2URLProps>();
const [showTraceDetails, setShowTraceDetails] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);

View File

@@ -72,7 +72,7 @@ function FunnelDetailsView({
<FunnelConfiguration
funnel={funnel}
isTraceDetailsPage
span={span}
span={span as any}
triggerAutoSave={triggerAutoSave}
showNotifications={showNotifications}
/>

View File

@@ -1,45 +1,35 @@
import { fireEvent, screen } from '@testing-library/react';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { render } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { Span } from 'types/api/trace/getTraceV2';
import SpanLineActionButtons from '../index';
// Mock the useCopySpanLink hook
jest.mock('hooks/trace/useCopySpanLink');
const mockSpan: SpanV3 = {
span_id: 'test-span-id',
trace_id: 'test-trace-id',
parent_span_id: 'test-parent-span-id',
timestamp: 1234567890,
duration_nano: 1000,
const mockSpan: Span = {
spanId: 'test-span-id',
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',
serviceName: 'test-service',
durationNano: 1000,
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
kind: 0,
kind_string: 'test-span-kind',
has_children: false,
has_sibling: false,
sub_tree_node_count: 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,
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', () => {
@@ -104,7 +94,7 @@ describe('SpanLineActionButtons', () => {
event.preventDefault();
event.stopPropagation();
mockUrlQuery.delete('spanId');
mockUrlQuery.set('spanId', mockSpan.span_id);
mockUrlQuery.set('spanId', mockSpan.spanId);
const link = `${
window.location.origin
}${mockPathname}?${mockUrlQuery.toString()}`;

View File

@@ -7,12 +7,12 @@ import {
} from '@signozhq/ui/tooltip';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { Link } from '@signozhq/icons';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { Span } from 'types/api/trace/getTraceV2';
import styles from './SpanLineActionButtons.module.scss';
export interface SpanLineActionButtonsProps {
span: SpanV3;
span: Span;
}
export default function SpanLineActionButtons({
span,

View File

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

View File

@@ -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 { SpanV3 } from 'types/api/trace/getTraceV3';
import { Span } from 'types/api/trace/getTraceV2';
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?: SpanV3;
span?: Span;
triggerAutoSave?: boolean;
showNotifications?: boolean;
}

View File

@@ -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 { SpanV3 } from 'types/api/trace/getTraceV3';
import { Span } from 'types/api/trace/getTraceV2';
import FunnelStep from './FunnelStep';
import InterStepConfig from './InterStepConfig';
@@ -18,7 +18,7 @@ function StepsContent({
span,
}: {
isTraceDetailsPage?: boolean;
span?: SpanV3;
span?: Span;
}): 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['service.name'], span.name);
handleReplaceStep(steps.length, span.serviceName, 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['service.name'] &&
(step.service_name === span.serviceName &&
step.span_name === span.name) ||
!hasEditPermission
}
onClick={(): void =>
handleReplaceStep(index, span['service.name'], span.name)
handleReplaceStep(index, span.serviceName, span.name)
}
>
Replace

View File

@@ -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},
Legend: Legend{Position: LegendPositionBottom, Mode: LegendModeList},
},
},
Queries: []Query{

View File

@@ -48,7 +48,42 @@ 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)
}
@@ -69,6 +104,13 @@ func (d *DashboardSpec) Validate() 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)
@@ -96,12 +138,35 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
return nil
}
var (
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
// 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)
}
}
}
)
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
}

View File

@@ -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")
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard").WithAdditional(err.Error())
}
out := &UpdatableDashboardV2{}
if err := json.Unmarshal(patched, out); err != nil {

View File

@@ -405,6 +405,7 @@ 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)

View File

@@ -112,6 +112,174 @@ 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
@@ -270,6 +438,65 @@ 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
@@ -569,6 +796,24 @@ 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: `{
@@ -634,6 +879,39 @@ 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": {
@@ -749,11 +1027,6 @@ 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"}]}`),
@@ -811,10 +1084,11 @@ 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, "solid", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default solid")
require.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
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.
@@ -825,9 +1099,10 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
"decimalPrecision": `"2"`,
"lineInterpolation": `"spline"`,
"lineStyle": `"solid"`,
"fillMode": `"solid"`,
"fillMode": `"none"`,
"timePreference": `"global_time"`,
"position": `"bottom"`,
"mode": `"list"`,
} {
assert.Contains(t, outputStr, `"`+field+`":`+want, "expected stored/response JSON to contain %s:%s", field, want)
}
@@ -930,7 +1205,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, "solid", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
assert.Equal(t, "none", 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)
@@ -950,7 +1225,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, "solid", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
assert.Equal(t, "none", 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)
@@ -966,7 +1241,7 @@ func TestStorageRoundTrip(t *testing.T) {
"decimalPrecision": `"2"`,
"lineInterpolation": `"spline"`,
"lineStyle": `"solid"`,
"fillMode": `"solid"`,
"fillMode": `"none"`,
"timePreference": `"global_time"`,
"position": `"bottom"`,
"format": `"text"`,

View File

@@ -30,6 +30,7 @@ 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]()},
}

View File

@@ -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,8 +297,7 @@ func extractKindAndSpec(data []byte) (string, []byte, error) {
return head.Kind, head.Spec, nil
}
// 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) {
func decodeSpec[T any](specJSON []byte, target T, kind string) (*T, error) {
if len(specJSON) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "kind %q: spec is required", kind)
}
@@ -310,7 +309,12 @@ func decodeSpec(specJSON []byte, target any, kind string) (any, error) {
if err := validator.New().Struct(target); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: spec failed validation", kind)
}
return target, nil
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
}
// signozDiscriminatorKey is the extension key that signoz.attachDiscriminators

View File

@@ -4,9 +4,11 @@ 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"
@@ -84,7 +86,7 @@ type QuerySpec struct {
// ══════════════════════════════════════════════
// Variable is the list/text sum type. Spec is set to *ListVariableSpec or
// *dashboard.TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// *TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// discriminated oneOf (see JSONSchemaOneOf).
type Variable struct {
Kind variable.Kind `json:"kind" required:"true"`
@@ -94,7 +96,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("DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec"),
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec"),
})
}
@@ -110,14 +112,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(dashboard.TextVariableSpec), kind)
spec, err := decodeSpec(specJSON, new(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}))
}
@@ -127,7 +129,7 @@ func (v *Variable) UnmarshalJSON(data []byte) error {
func (Variable) JSONSchemaOneOf() []any {
return []any{
VariableEnvelope[ListVariableSpec]{Kind: string(variable.KindList)},
VariableEnvelope[dashboard.TextVariableSpec]{Kind: string(variable.KindText)},
VariableEnvelope[TextVariableSpec]{Kind: string(variable.KindText)},
}
}
@@ -143,15 +145,106 @@ 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" required:"true"`
Display *Display `json:"display,omitempty"`
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 *variable.Sort `json:"sort,omitempty"`
Sort ListVariableSpecSort `json:"sort,omitzero"`
Plugin VariablePlugin `json:"plugin"`
Name string `json:"name"`
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
}
// ══════════════════════════════════════════════
@@ -194,7 +287,7 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
return err
}
l.Kind = dashboard.LayoutKind(kind)
l.Spec = spec
l.Spec = *spec
return nil
}

View File

@@ -241,6 +241,7 @@ type TableFormatting struct {
type Legend struct {
Position LegendPosition `json:"position"`
Mode LegendMode `json:"mode"`
CustomColors map[string]string `json:"customColors"`
}
@@ -248,7 +249,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" validate:"required" required:"true"`
Label string `json:"label"`
}
type ComparisonThreshold struct {
@@ -358,6 +359,47 @@ 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 (
@@ -534,9 +576,9 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
type FillMode struct{ valuer.String }
var (
FillModeSolid = FillMode{valuer.NewString("solid")} // default
FillModeSolid = FillMode{valuer.NewString("solid")}
FillModeGradient = FillMode{valuer.NewString("gradient")}
FillModeNone = FillMode{valuer.NewString("none")}
FillModeNone = FillMode{valuer.NewString("none")} // default
)
func (FillMode) Enum() []any {
@@ -545,7 +587,7 @@ func (FillMode) Enum() []any {
func (fm FillMode) ValueOrDefault() string {
if fm.IsZero() {
return FillModeSolid.StringValue()
return FillModeNone.StringValue()
}
return fm.StringValue()
}
@@ -560,7 +602,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 = FillModeSolid
*fm = FillModeNone
return nil
}
val := FillMode{valuer.NewString(v)}
@@ -573,12 +615,9 @@ 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"`
FillLessThan valuer.TextDuration `json:"fillLessThan"`
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."`
}
type PrecisionOption struct{ valuer.String }