Compare commits

..

14 Commits

Author SHA1 Message Date
Aditya Singh
a58539e25c Merge branch 'main' into feat/trace-details-pending 2026-05-04 18:04:23 +05:30
aks07
eeb7fa3aa5 feat: lint fix 2026-05-04 18:03:49 +05:30
Pandey
680bcd08c3 fix(types): correct OpenAPI schema for AuthDomainConfig and PostableChannel (#11164)
* fix(authtypes): embed values and expose AuthDomainConfig oneOf

GettableAuthDomain now embeds StorableAuthDomain and AuthDomainConfig
by value so the response flattens correctly. AuthDomainConfig also
implements jsonschema.OneOfExposer over the SAML/Google/OIDC variants.

* fix(alertmanagertypes): expose PostableChannel JSONSchema

PostableChannel now implements jsonschema.Exposer, requiring name
and a oneOf branch per *_configs field so the OpenAPI request body
for POST /channels matches the runtime contract enforced in
NewChannelFromReceiver. Switched the route's Request type from
Receiver to PostableChannel and regenerated the OpenAPI spec.

* fix(alertmanagertypes): use components/schemas prefix in PostableChannel refs

The standalone reflector inside JSONSchema defaulted to #/definitions/
prefix, producing dangling refs to ConfigDiscordConfig etc. that broke
the generated frontend client. Pass DefinitionsPrefix("#/components/schemas/")
so refs point to existing OpenAPI components, and regenerate the frontend
Orval client.

* feat(authdomain): add GET /api/v1/domains/{id} endpoint

Returns a single GettableAuthDomain scoped to the caller's organization,
backed by the existing module.GetByOrgIDAndID. Adds Get to the Handler
interface, wires the route under AdminAccess, and regenerates the
OpenAPI spec and frontend Orval client.

* feat(authtypes): expose AuthNProvider enum in OpenAPI schema

AuthNProvider now implements jsonschema.Enum, narrowing the generated
TypeScript type from string to a typed enum. Updated callers in the
auth-domain settings UI and mocks to use AuthtypesAuthNProviderDTO,
and added an early-return guard in the create/edit submit handler so
TS can narrow the union before passing it as ssoType.

* chore(types): document oneOf/discriminator mismatch on PostableChannel and AuthDomainConfig

Both types emit a oneOf in the OpenAPI spec but neither shape supports an
OpenAPI discriminator: PostableChannel implies the variant by which *_configs
field is present, and AuthDomainConfig keeps the variant payload in a
sibling field instead of being the payload itself. Leave a TODO pointing at
ruletypes.RuleThresholdData as the envelope pattern to migrate to.

* fix(ruletypes): handle string driver values in Schedule.Scan and Recurrence.Scan

The Scan methods only handled []byte and silently no-op'd on anything
else. SQLite's TEXT columns come back as string from the driver, so
every GET of a planned_maintenance returned a zero-valued Schedule
(empty timezone, 0001-01-01 startTime/endTime, no recurrence) — even
though Create + Update wrote the values correctly.

Switch on src type, accept []byte, string, and nil; error on anything
else. Aligns Schedule with the existing pattern; in Recurrence fixes
the receiver — Unmarshal was being passed src (the interface{} arg)
rather than r.
2026-05-04 18:00:43 +05:30
aks07
d11234531d Merge branch 'feat/dropdown-items' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-04 17:56:01 +05:30
aks07
0bd591458d Merge branch 'feat/dropdown-items' of github.com:SigNoz/signoz into feat/trace-details-pending 2026-05-04 16:59:26 +05:30
aks07
33520c41c8 fix: scroll to span in frontend mode 2026-05-04 11:32:13 +05:30
aks07
b994d6dd8e feat: fix color 2026-04-25 16:22:48 +05:30
aks07
5e231e799e feat: filter toggle added 2026-04-25 11:26:59 +05:30
aks07
5f4a79c201 feat: filters init 2026-04-23 18:58:51 +05:30
aks07
8edf375019 feat: floating fields set 2026-04-22 21:11:33 +05:30
aks07
0d1fd6d0bd feat: minor changes 2026-04-22 18:47:47 +05:30
aks07
fefd0effef feat: add trace details header styles 2026-04-22 17:59:48 +05:30
aks07
36a137be4d feat: add trace details header styles 2026-04-22 15:49:17 +05:30
aks07
68dc7e426a feat: add trace details header 2026-04-22 15:32:01 +05:30
39 changed files with 3301 additions and 200 deletions

View File

@@ -96,6 +96,122 @@ components:
- createdAt
- updatedAt
type: object
AlertmanagertypesPostableChannel:
oneOf:
- required:
- discord_configs
- required:
- email_configs
- required:
- incidentio_configs
- required:
- pagerduty_configs
- required:
- slack_configs
- required:
- webhook_configs
- required:
- opsgenie_configs
- required:
- wechat_configs
- required:
- pushover_configs
- required:
- victorops_configs
- required:
- sns_configs
- required:
- telegram_configs
- required:
- webex_configs
- required:
- msteams_configs
- required:
- msteamsv2_configs
- required:
- jira_configs
- required:
- rocketchat_configs
- required:
- mattermost_configs
properties:
discord_configs:
items:
$ref: '#/components/schemas/ConfigDiscordConfig'
type: array
email_configs:
items:
$ref: '#/components/schemas/ConfigEmailConfig'
type: array
incidentio_configs:
items:
$ref: '#/components/schemas/ConfigIncidentioConfig'
type: array
jira_configs:
items:
$ref: '#/components/schemas/ConfigJiraConfig'
type: array
mattermost_configs:
items:
$ref: '#/components/schemas/ConfigMattermostConfig'
type: array
msteams_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsConfig'
type: array
msteamsv2_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsV2Config'
type: array
name:
type: string
opsgenie_configs:
items:
$ref: '#/components/schemas/ConfigOpsGenieConfig'
type: array
pagerduty_configs:
items:
$ref: '#/components/schemas/ConfigPagerdutyConfig'
type: array
pushover_configs:
items:
$ref: '#/components/schemas/ConfigPushoverConfig'
type: array
rocketchat_configs:
items:
$ref: '#/components/schemas/ConfigRocketchatConfig'
type: array
slack_configs:
items:
$ref: '#/components/schemas/ConfigSlackConfig'
type: array
sns_configs:
items:
$ref: '#/components/schemas/ConfigSNSConfig'
type: array
telegram_configs:
items:
$ref: '#/components/schemas/ConfigTelegramConfig'
type: array
victorops_configs:
items:
$ref: '#/components/schemas/ConfigVictorOpsConfig'
type: array
webex_configs:
items:
$ref: '#/components/schemas/ConfigWebexConfig'
type: array
webhook_configs:
items:
$ref: '#/components/schemas/ConfigWebhookConfig'
type: array
wechat_configs:
items:
$ref: '#/components/schemas/ConfigWechatConfig'
type: array
required:
- name
type: object
AlertmanagertypesPostableRoutePolicy:
properties:
channels:
@@ -133,6 +249,10 @@ components:
type: string
type: object
AuthtypesAuthDomainConfig:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
@@ -145,8 +265,15 @@ components:
ssoEnabled:
type: boolean
ssoType:
type: string
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesAuthNProvider:
enum:
- google_auth
- saml
- email_password
- oidc
type: string
AuthtypesAuthNProviderInfo:
properties:
relayStatePath:
@@ -169,11 +296,15 @@ components:
AuthtypesCallbackAuthNSupport:
properties:
provider:
type: string
$ref: '#/components/schemas/AuthtypesAuthNProvider'
url:
type: string
type: object
AuthtypesGettableAuthDomain:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
authNProviderInfo:
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
@@ -197,7 +328,7 @@ components:
ssoEnabled:
type: boolean
ssoType:
type: string
$ref: '#/components/schemas/AuthtypesAuthNProvider'
updatedAt:
format: date-time
type: string
@@ -323,7 +454,7 @@ components:
AuthtypesPasswordAuthNSupport:
properties:
provider:
type: string
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesPatchableObjects:
properties:
@@ -5665,7 +5796,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigReceiver'
$ref: '#/components/schemas/AlertmanagertypesPostableChannel'
responses:
"201":
content:
@@ -7042,6 +7173,63 @@ paths:
summary: Delete auth domain
tags:
- authdomains
get:
deprecated: false
description: This endpoint returns an auth domain by ID
operationId: GetAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get auth domain by ID
tags:
- authdomains
put:
deprecated: false
description: This endpoint updates an auth domain

View File

@@ -22,6 +22,8 @@ import type {
AuthtypesUpdateableAuthDomainDTO,
CreateAuthDomain200,
DeleteAuthDomainPathParameters,
GetAuthDomain200,
GetAuthDomainPathParameters,
ListAuthDomains200,
RenderErrorResponseDTO,
UpdateAuthDomainPathParameters,
@@ -277,6 +279,109 @@ export const useDeleteAuthDomain = <
return useMutation(mutationOptions);
};
/**
* This endpoint returns an auth domain by ID
* @summary Get auth domain by ID
*/
export const getAuthDomain = (
{ id }: GetAuthDomainPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAuthDomain200>({
url: `/api/v1/domains/${id}`,
method: 'GET',
signal,
});
};
export const getGetAuthDomainQueryKey = ({
id,
}: GetAuthDomainPathParameters) => {
return [`/api/v1/domains/${id}`] as const;
};
export const getGetAuthDomainQueryOptions = <
TData = Awaited<ReturnType<typeof getAuthDomain>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetAuthDomainPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetAuthDomainQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAuthDomain>>> = ({
signal,
}) => getAuthDomain({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAuthDomainQueryResult = NonNullable<
Awaited<ReturnType<typeof getAuthDomain>>
>;
export type GetAuthDomainQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get auth domain by ID
*/
export function useGetAuthDomain<
TData = Awaited<ReturnType<typeof getAuthDomain>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetAuthDomainPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAuthDomainQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get auth domain by ID
*/
export const invalidateGetAuthDomain = async (
queryClient: QueryClient,
{ id }: GetAuthDomainPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAuthDomainQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint updates an auth domain
* @summary Update auth domain

View File

@@ -18,6 +18,7 @@ import type {
} from 'react-query';
import type {
AlertmanagertypesPostableChannelDTO,
ConfigReceiverDTO,
CreateChannel201,
DeleteChannelByIDPathParameters,
@@ -122,14 +123,14 @@ export const invalidateListChannels = async (
* @summary Create notification channel
*/
export const createChannel = (
configReceiverDTO: BodyType<ConfigReceiverDTO>,
alertmanagertypesPostableChannelDTO: BodyType<AlertmanagertypesPostableChannelDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateChannel201>({
url: `/api/v1/channels`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: configReceiverDTO,
data: alertmanagertypesPostableChannelDTO,
signal,
});
};
@@ -141,13 +142,13 @@ export const getCreateChannelMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<ConfigReceiverDTO> },
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<ConfigReceiverDTO> },
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
TContext
> => {
const mutationKey = ['createChannel'];
@@ -161,7 +162,7 @@ export const getCreateChannelMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createChannel>>,
{ data: BodyType<ConfigReceiverDTO> }
{ data: BodyType<AlertmanagertypesPostableChannelDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -174,7 +175,8 @@ export const getCreateChannelMutationOptions = <
export type CreateChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof createChannel>>
>;
export type CreateChannelMutationBody = BodyType<ConfigReceiverDTO>;
export type CreateChannelMutationBody =
BodyType<AlertmanagertypesPostableChannelDTO>;
export type CreateChannelMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -187,13 +189,13 @@ export const useCreateChannel = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<ConfigReceiverDTO> },
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<ConfigReceiverDTO> },
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
TContext
> => {
const mutationOptions = getCreateChannelMutationOptions(options);

File diff suppressed because it is too large Load Diff

View File

@@ -38,5 +38,6 @@ export enum LOCALSTORAGE {
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
TRACE_DETAILS_SHOW_TRACE_OVERVIEW = 'TRACE_DETAILS_SHOW_TRACE_OVERVIEW',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
}

View File

@@ -1,10 +1,11 @@
import { GoogleSquareFilled, KeyOutlined } from '@ant-design/icons';
import { Button, Typography } from 'antd';
import { AuthtypesAuthNProviderDTO } from 'api/generated/services/sigNoz.schemas';
import './CreateEdit.styles.scss';
interface AuthNProvider {
key: string;
key: AuthtypesAuthNProviderDTO;
title: string;
description: string;
icon: JSX.Element;
@@ -14,14 +15,14 @@ interface AuthNProvider {
function getAuthNProviders(samlEnabled: boolean): AuthNProvider[] {
return [
{
key: 'google_auth',
key: AuthtypesAuthNProviderDTO.google_auth,
title: 'Google Apps Authentication',
description: 'Let members sign-in with a Google workspace account',
icon: <GoogleSquareFilled style={{ fontSize: '37px' }} />,
enabled: true,
},
{
key: 'saml',
key: AuthtypesAuthNProviderDTO.saml,
title: 'SAML Authentication',
description:
'Azure, Active Directory, Okta or your custom SAML 2.0 solution',
@@ -30,7 +31,7 @@ function getAuthNProviders(samlEnabled: boolean): AuthNProvider[] {
},
{
key: 'oidc',
key: AuthtypesAuthNProviderDTO.oidc,
title: 'OIDC Authentication',
description:
'Authenticate using OpenID Connect providers like Azure, Active Directory, Okta, or other OIDC compliant solutions',
@@ -44,7 +45,9 @@ function AuthnProviderSelector({
setAuthnProvider,
samlEnabled,
}: {
setAuthnProvider: React.Dispatch<React.SetStateAction<string>>;
setAuthnProvider: React.Dispatch<
React.SetStateAction<AuthtypesAuthNProviderDTO | ''>
>;
samlEnabled: boolean;
}): JSX.Element {
const authnProviders = getAuthNProviders(samlEnabled);

View File

@@ -7,6 +7,7 @@ import {
useUpdateAuthDomain,
} from 'api/generated/services/authdomains';
import {
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesRoleMappingDTO,
@@ -57,9 +58,9 @@ interface CreateOrEditProps {
function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const { isCreate, record, onClose } = props;
const [form] = Form.useForm<FormValues>();
const [authnProvider, setAuthnProvider] = useState<string>(
record?.ssoType || '',
);
const [authnProvider, setAuthnProvider] = useState<
AuthtypesAuthNProviderDTO | ''
>(record?.ssoType || '');
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();
@@ -138,6 +139,10 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return;
}
if (authnProvider === '') {
return;
}
const name = form.getFieldValue('name');
const googleAuthConfig = getGoogleAuthConfig();
const samlConfig = form.getFieldValue('samlConfig');

View File

@@ -1,4 +1,7 @@
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
} from 'api/generated/services/sigNoz.schemas';
// API Endpoints
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v1/domains';
@@ -11,7 +14,7 @@ export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-1',
name: 'signoz.io',
ssoEnabled: true,
ssoType: 'google_auth',
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
@@ -26,7 +29,7 @@ export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-2',
name: 'example.com',
ssoEnabled: false,
ssoType: 'saml',
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
@@ -42,7 +45,7 @@ export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-3',
name: 'corp.io',
ssoEnabled: true,
ssoType: 'oidc',
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
@@ -58,7 +61,7 @@ export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-4',
name: 'enterprise.com',
ssoEnabled: true,
ssoType: 'saml',
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
@@ -84,7 +87,7 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
id: 'domain-5',
name: 'direct-role.com',
ssoEnabled: true,
ssoType: 'oidc',
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
@@ -104,7 +107,7 @@ export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-6',
name: 'oidc-claims.com',
ssoEnabled: true,
ssoType: 'oidc',
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
@@ -129,7 +132,7 @@ export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-7',
name: 'saml-attrs.com',
ssoEnabled: true,
ssoType: 'saml',
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
@@ -152,7 +155,7 @@ export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO =
id: 'domain-8',
name: 'google-groups.com',
ssoEnabled: true,
ssoType: 'google_auth',
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',

View File

@@ -29,7 +29,7 @@ export function EventTooltipContent({
{eventName}
</div>
<div className="event-tooltip-content__time">
{toFixed(time, 2)} {timeUnitName} from start
{toFixed(time, 2)} {timeUnitName} since span start
</div>
{Object.keys(attributeMap).length > 0 && (
<>

View File

@@ -1,3 +1,8 @@
.trace-details-header-wrapper {
flex-shrink: 0;
position: relative;
}
.trace-details-header {
display: flex;
align-items: center;
@@ -16,13 +21,47 @@
&.trace-v3-filter-row {
padding: 0;
}
max-width: 850px;
flex: 1;
min-width: 0;
&:not(&--expanded) {
margin-left: auto;
}
&--expanded {
max-width: none;
flex: 1;
}
}
&__old-view-btn {
margin-left: auto;
flex-shrink: 0;
}
&__sub-header {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 16px 8px;
font-size: 13px;
color: var(--l2-foreground);
}
&__sub-item {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
&__separator {
color: var(--l2-foreground);
opacity: 0.5;
}
&__entry-point-badge {
padding: 2px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
font-size: 12px;
}
}

View File

@@ -1,13 +1,20 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { Button } from '@signozhq/ui';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import history from 'lib/history';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, CalendarClock, Server, Timer } from 'lucide-react';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import TraceOptionsMenu from './TraceOptionsMenu';
import './TraceDetailsHeader.styles.scss';
@@ -17,18 +24,34 @@ interface FilterMetadata {
traceId: string;
}
export interface TraceMetadataForHeader {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
}
interface TraceDetailsHeaderProps {
filterMetadata: FilterMetadata;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
noData?: boolean;
isDataLoaded?: boolean;
traceMetadata?: TraceMetadataForHeader;
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
noData,
isDataLoaded,
traceMetadata,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV2URLProps>();
const [showTraceDetails, setShowTraceDetails] = useState(
() =>
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SHOW_TRACE_OVERVIEW) ===
'true',
);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const handleSwitchToOldView = useCallback((): void => {
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
@@ -49,43 +72,126 @@ function TraceDetailsHeader({
}
}, []);
const handleToggleTraceDetails = useCallback((): void => {
setShowTraceDetails((prev) => {
const next = !prev;
setLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SHOW_TRACE_OVERVIEW,
String(next),
);
return next;
});
}, []);
const durationMs = traceMetadata
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
: 0;
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
return (
<div className="trace-details-header">
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__back-btn"
onClick={handlePreviousBtnClick}
>
<ArrowLeft size={14} />
</Button>
<KeyValueLabel
badgeKey="Trace ID"
badgeValue={traceID || ''}
maxCharacters={100}
/>
{!noData && (
<>
<div className="trace-details-header__filter">
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
<div className="trace-details-header-wrapper">
<div className="trace-details-header">
{!isFilterExpanded && (
<>
<Button
variant="solid"
color="secondary"
size="md"
className="trace-details-header__back-btn"
onClick={handlePreviousBtnClick}
>
<ArrowLeft size={14} />
</Button>
<KeyValueLabel
badgeKey="Trace ID"
badgeValue={traceID || ''}
maxCharacters={100}
/>
</div>
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
</>
</>
)}
{isDataLoaded && (
<>
<div
className={`trace-details-header__filter${
isFilterExpanded ? ' trace-details-header__filter--expanded' : ''
}`}
>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
isExpanded={isFilterExpanded}
onExpand={(): void => setIsFilterExpanded(true)}
onCollapse={(): void => setIsFilterExpanded(false)}
/>
</div>
{!isFilterExpanded && (
<>
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
/>
</>
)}
</>
)}
</div>
{showTraceDetails && traceMetadata && (
<div className="trace-details-header__sub-header">
<span className="trace-details-header__sub-item">
<Server size={13} />
{traceMetadata.rootServiceName}
<span className="trace-details-header__separator"></span>
<span className="trace-details-header__entry-point-badge">
{traceMetadata.rootServiceEntryPoint}
</span>
</span>
<span className="trace-details-header__sub-item">
<Timer size={13} />
{parseFloat(formattedDuration.toFixed(2))} {timeUnitName}
</span>
<span className="trace-details-header__sub-item">
<CalendarClock size={13} />
{dayjs(traceMetadata.startTimestampMillis).format('D MMM YYYY, HH:mm:ss')}
</span>
{traceMetadata.rootSpanStatusCode && (
<HttpStatusBadge statusCode={traceMetadata.rootSpanStatusCode} />
)}
</div>
)}
{/* {isPreviewFieldsOpen && (
<FloatingPanel
isOpen
width={350}
height={window.innerHeight - 100}
defaultPosition={{
x: window.innerWidth - 350 - 100,
y: 50,
}}
enableResizing={false}
>
<FieldsSettings
title="Preview fields"
fields={previewFields}
onFieldsChange={setPreviewFields}
onClose={(): void => setIsPreviewFieldsOpen(false)}
dataSource={DataSource.TRACES}
/>
</FloatingPanel>
)} */}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useMemo } from 'react';
import type { MenuItem } from '@signozhq/ui';
import { Button, Dropdown } from '@signozhq/ui';
import { Ellipsis } from 'lucide-react';
interface TraceOptionsMenuProps {
showTraceDetails: boolean;
onToggleTraceDetails: () => void;
}
function TraceOptionsMenu({
showTraceDetails,
onToggleTraceDetails,
}: TraceOptionsMenuProps): JSX.Element {
const menuItems: MenuItem[] = useMemo(
() => [
{
key: 'toggle-trace-details',
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
onClick: onToggleTraceDetails,
},
// {
// key: 'preview-fields',
// label: 'Preview fields',
// onClick: (): void => setIsPreviewFieldsOpen(!isPreviewFieldsOpen),
// },
],
[showTraceDetails, onToggleTraceDetails],
);
return (
<Dropdown menu={{ items: menuItems }}>
<Button variant="solid" color="secondary" size="sm">
<Ellipsis size={14} />
</Button>
</Dropdown>
);
}
export default TraceOptionsMenu;

View File

@@ -229,10 +229,7 @@ export function useFlamegraphDraw(
const eventRectsRef = eventRectsRefProp ?? eventRectsRefInternal;
const filteredSpanIdsSet = useMemo(
() =>
isFilterActive && filteredSpanIds && filteredSpanIds.length > 0
? new Set(filteredSpanIds)
: null,
() => (isFilterActive && filteredSpanIds ? new Set(filteredSpanIds) : null),
[filteredSpanIds, isFilterActive],
);

View File

@@ -211,7 +211,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
// Alpha is applied to bar + events only; label is drawn after restoring alpha to 1
// so text stays readable against the faded bar.
if (shouldDim) {
ctx.globalAlpha = 0.4;
ctx.globalAlpha = 0.15;
}
ctx.beginPath();

View File

@@ -3,10 +3,116 @@
align-items: center;
gap: 12px;
&.expanded {
flex: 1;
}
.filter-search-container {
flex: 1;
min-width: 0;
}
.query-builder-search-v2 {
width: 100%;
}
// --- Collapsed pill ---
.filter-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--l2-border);
border-radius: 4px;
cursor: pointer;
max-width: 220px;
min-width: 120px;
height: 32px;
background: var(--l1-background);
&:hover {
border-color: var(--primary-background-hover);
}
&__text {
font-size: 12px;
color: var(--l2-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
&__indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-robin-500);
flex-shrink: 0;
}
}
// --- Collapsed pill popover ---
.filter-pill-popover {
max-width: 400px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.ant-typography {
font-size: 12px;
font-weight: 500;
color: var(--l2-foreground);
}
}
&__expression {
font-family: 'Geist Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--l1-foreground);
word-break: break-all;
padding: 6px 8px;
background: var(--l2-background);
border-radius: 4px;
}
}
// --- ToggleGroup override: size to content, don't stretch items ---
[class*='toggle-group'] {
flex-shrink: 0;
[class*='toggle-group-item'] {
flex: 0 0 auto;
}
}
// --- Collapse button ---
.filter-collapse-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
// --- Highlight errors toggle ---
.highlight-errors-toggle {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
white-space: nowrap;
.ant-typography {
color: var(--l2-foreground);
font-size: 12px;
}
}
// --- Prev/next navigation ---
.pre-next-toggle {
display: flex;
flex-shrink: 0;

View File

@@ -1,19 +1,31 @@
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import { Button, Spin, Tooltip, Typography } from 'antd';
import { Switch, ToggleGroup, ToggleGroupItem } from '@signozhq/ui';
import { toast } from '@signozhq/ui';
import { Button, Popover, Spin, Tooltip, Typography } from 'antd';
import { AxiosError } from 'axios';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { ChevronDown, ChevronUp, Copy, Search, X } from 'lucide-react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import {
DataSource,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { BASE_FILTER_QUERY } from './constants';
import { useHighlightErrors } from './hooks/useHighlightErrors';
import {
SpanCategory,
useSpanCategoryFilter,
} from './hooks/useSpanCategoryFilter';
import './Filters.styles.scss';
@@ -44,6 +56,7 @@ function prepareQuery(filters: TagFilter, traceID: string): Query {
},
],
},
selectColumns: [],
},
],
},
@@ -55,31 +68,87 @@ function Filters({
endTime,
traceID,
onFilteredSpansChange = (): void => {},
isExpanded,
onExpand,
onCollapse,
}: {
startTime: number;
endTime: number;
traceID: string;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
isExpanded: boolean;
onExpand: () => void;
onCollapse: () => void;
}): JSX.Element {
const [, setCopy] = useCopyToClipboard();
const [filters, setFilters] = useState<TagFilter>(
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
);
const [expression, setExpression] = useState<string>('');
const [noData, setNoData] = useState<boolean>(false);
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
const expressionRef = useRef<string>('');
const containerRef = useRef<HTMLDivElement>(null);
const handleFilterChange = useCallback(
(value: TagFilter): void => {
if (value.items.length === 0) {
const runQuery = useCallback(
(value: string): void => {
const items = convertExpressionToFilters(value);
setFilters({ items, op: 'AND' });
// Clear results when expression produces no filters
if (items.length === 0) {
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
setFilters(value);
},
[onFilteredSpansChange],
);
// onChange fires on every keystroke — only store the expression, don't trigger API
const handleExpressionChange = useCallback(
(value: string): void => {
setExpression(value);
expressionRef.current = value;
// Clear results when expression is emptied
if (!value.trim()) {
setFilters({ items: [], op: 'AND' });
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
},
[onFilteredSpansChange],
);
// onRun fires on Ctrl+Enter
const handleRunQuery = useCallback(
(value: string): void => {
runQuery(value);
},
[runQuery],
);
// Run query on blur (click outside the filter input)
const handleBlur = useCallback((): void => {
runQuery(expressionRef.current);
}, [runQuery]);
// Expression-based filter hooks
const filterProps = {
expression,
filters,
setExpression,
expressionRef,
runQuery,
};
const { isHighlightErrors, handleToggle: handleToggleHighlightErrors } =
useHighlightErrors(filterProps);
const { selectedCategory, categories, handleCategoryChange } =
useSpanCategoryFilter(filterProps);
const { search } = useLocation();
const history = useHistory();
@@ -110,14 +179,14 @@ function Filters({
tableParams: {
pagination: {
offset: 0,
limit: 200,
limit: 10000,
},
selectColumns: [
{
key: 'name',
key: 'spanID',
dataType: 'string',
type: 'tag',
id: 'name--string--tag--true',
id: 'spanId--string--tag--true',
isIndexed: false,
},
],
@@ -150,18 +219,117 @@ function Filters({
},
);
return (
<div className="trace-v3-filter-row">
<QueryBuilderSearchV2
query={{
...BASE_FILTER_QUERY,
filters,
}}
onChange={handleFilterChange}
hideSpanScopeSelector={false}
skipQueryBuilderRedirect
selectProps={{ listHeight: 125 }}
const highlightErrorsToggle = (
<div className="highlight-errors-toggle">
<Typography.Text>Highlight errors</Typography.Text>
<Switch
color="cherry"
value={isHighlightErrors}
onChange={handleToggleHighlightErrors}
/>
</div>
);
const statusIndicators = (
<>
{isFetching && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<InfoCircleOutlined size={14} />
</Tooltip>
)}
{noData && (
<Typography.Text className="no-results">No results found</Typography.Text>
)}
</>
);
// --- COLLAPSED VIEW ---
if (!isExpanded) {
return (
<div className="trace-v3-filter-row collapsed">
<Popover
content={
expression ? (
<div className="filter-pill-popover">
<div className="filter-pill-popover__header">
<Typography.Text>Search query</Typography.Text>
<Button
type="text"
size="small"
icon={<Copy size={12} />}
onClick={(): void => {
setCopy(expression);
toast.success('Copied to clipboard', {
richColors: false,
position: 'top-right',
});
}}
/>
</div>
<div className="filter-pill-popover__expression">{expression}</div>
</div>
) : null
}
trigger="hover"
placement="bottomLeft"
arrow={false}
overlayStyle={{ maxWidth: 400 }}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="filter-pill" onClick={onExpand}>
<Search size={12} />
<span className="filter-pill__text">{expression || 'Search...'}</span>
{expression && <span className="filter-pill__indicator" />}
</div>
</Popover>
{highlightErrorsToggle}
{statusIndicators}
</div>
);
}
// --- EXPANDED VIEW ---
return (
<div className="trace-v3-filter-row expanded">
<ToggleGroup
type="single"
value={selectedCategory}
onChange={(value): void => {
if (value) {
handleCategoryChange(value as SpanCategory);
}
}}
size="sm"
>
{categories.map((category) => (
<ToggleGroupItem key={category} value={category}>
{category}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="filter-search-container"
ref={containerRef}
onBlur={(e): void => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
handleBlur();
}
}}
>
<QuerySearch
queryData={{
...BASE_FILTER_QUERY,
filters,
filter: { expression },
}}
onChange={handleExpressionChange}
onRun={handleRunQuery}
dataSource={DataSource.TRACES}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
/>
</div>
{filteredSpanIds.length > 0 && (
<div className="pre-next-toggle">
<Typography.Text>
@@ -187,15 +355,14 @@ function Filters({
/>
</div>
)}
{isFetching && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<InfoCircleOutlined size={14} />
</Tooltip>
)}
{noData && (
<Typography.Text className="no-results">No results found</Typography.Text>
)}
<Button
type="text"
icon={<X size={14} />}
onClick={onCollapse}
className="filter-collapse-btn"
/>
{highlightErrorsToggle}
{statusIndicators}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { MutableRefObject } from 'react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
/**
* Shared props for expression-based filter hooks.
* Each hook reads the current expression + derived filters,
* and manipulates the expression via remove/add pattern.
*/
export interface ExpressionFilterProps {
expression: string;
filters: TagFilter;
setExpression: (expr: string) => void;
expressionRef: MutableRefObject<string>;
runQuery: (expr: string) => void;
}
/**
* Helper: update expression state, ref, and trigger query.
*/
export function applyExpression(
newExpression: string,
props: Pick<
ExpressionFilterProps,
'setExpression' | 'expressionRef' | 'runQuery'
>,
): void {
props.setExpression(newExpression);
props.expressionRef.current = newExpression;
props.runQuery(newExpression);
}

View File

@@ -0,0 +1,45 @@
import { useCallback, useMemo } from 'react';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import { applyExpression, ExpressionFilterProps } from './types';
interface UseHighlightErrorsReturn {
isHighlightErrors: boolean;
handleToggle: (checked: boolean) => void;
}
const ERROR_KEY = 'has_error';
export function useHighlightErrors(
props: ExpressionFilterProps,
): UseHighlightErrorsReturn {
const { expression, filters, setExpression, expressionRef, runQuery } = props;
// Derive from filters (only updates after runQuery, not on every keystroke)
const isHighlightErrors = useMemo(
() =>
filters.items.some(
(item) =>
item.key?.key === ERROR_KEY &&
(item.value === true || item.value === 'true'),
),
[filters],
);
const handleToggle = useCallback(
(checked: boolean): void => {
// Always remove existing has_error first (whatever its value)
let newExpr = removeKeysFromExpression(expression, [ERROR_KEY]);
// Add back if turning ON
if (checked) {
newExpr = newExpr.trim()
? `${newExpr.trim()} AND has_error = true`
: `has_error = true`;
}
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
},
[expression, setExpression, expressionRef, runQuery],
);
return { isHighlightErrors, handleToggle };
}

View File

@@ -0,0 +1,75 @@
import { useCallback, useMemo } from 'react';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import { applyExpression, ExpressionFilterProps } from './types';
export type SpanCategory = 'All' | 'Database' | 'Functions' | 'HTTP' | 'Jobs';
export const SPAN_CATEGORIES: readonly SpanCategory[] = [
'All',
'Database',
'Functions',
'HTTP',
'Jobs',
];
// Map each category to the attribute key it filters on
const CATEGORY_KEYS: Record<Exclude<SpanCategory, 'All'>, string> = {
Database: 'db.system',
HTTP: 'http.method',
Functions: 'kind_string',
Jobs: 'messaging.system',
};
// All category keys — used for bulk removal when switching categories
const ALL_CATEGORY_KEYS = Object.values(CATEGORY_KEYS);
// The expression clause to add for each category
const CATEGORY_EXPRESSIONS: Record<Exclude<SpanCategory, 'All'>, string> = {
Database: "db.system != ''",
HTTP: "http.method != ''",
Functions: "kind_string = 'Internal'",
Jobs: "messaging.system != ''",
};
interface UseSpanCategoryFilterReturn {
selectedCategory: SpanCategory;
categories: readonly SpanCategory[];
handleCategoryChange: (category: SpanCategory) => void;
}
export function useSpanCategoryFilter(
props: ExpressionFilterProps,
): UseSpanCategoryFilterReturn {
const { expression, filters, setExpression, expressionRef, runQuery } = props;
// Derive active category from filters (only updates after runQuery)
const selectedCategory = useMemo((): SpanCategory => {
for (const [category, key] of Object.entries(CATEGORY_KEYS)) {
if (filters.items.some((item) => item.key?.key === key)) {
return category as SpanCategory;
}
}
return 'All';
}, [filters]);
const handleCategoryChange = useCallback(
(category: SpanCategory): void => {
// Remove ALL category keys first
let newExpr = removeKeysFromExpression(expression, ALL_CATEGORY_KEYS);
// Add the selected category clause (unless "All")
if (category !== 'All') {
const clause = CATEGORY_EXPRESSIONS[category];
newExpr = newExpr.trim() ? `${newExpr.trim()} AND ${clause}` : clause;
}
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
},
[expression, setExpression, expressionRef, runQuery],
);
return {
selectedCategory,
categories: SPAN_CATEGORIES,
handleCategoryChange,
};
}

View File

@@ -244,7 +244,7 @@
}
&.dimmed-span {
opacity: 0.4;
opacity: 0.15;
}
}
}
@@ -540,7 +540,7 @@
}
.dimmed-span {
opacity: 0.4;
opacity: 0.15;
}
.highlighted-span {
opacity: 1;

View File

@@ -130,7 +130,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
});
// css config
const CONNECTOR_WIDTH = 20;
const CONNECTOR_WIDTH = 30;
const VERTICAL_CONNECTOR_WIDTH = 1;
interface SpanStateClasses {
@@ -534,14 +534,15 @@ function Success(props: ISuccessProps): JSX.Element {
}
return next;
});
} else {
// Backend mode: trigger API call (current behavior)
setInterestedSpanId({
spanId,
isUncollapsed: !collapse,
scrollToSpan: false,
});
}
// Backend mode: trigger API call (current behavior)
// keeping this for both mode to support scroll to view to function well.
// interestedspan would not make api call in frontend mode so it is safe to use for both mode.
setInterestedSpanId({
spanId,
isUncollapsed: !collapse,
scrollToSpan: false,
});
},
[isFullDataLoaded, setLocalUncollapsedNodes, setInterestedSpanId],
);
@@ -619,7 +620,7 @@ function Success(props: ISuccessProps): JSX.Element {
});
}
},
[spans, setInterestedSpanId],
[spans, setInterestedSpanId, isFullDataLoaded],
);
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] =

View File

@@ -0,0 +1,133 @@
import { useMemo } from 'react';
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from 'lucide-react';
function SortableField({
field,
onRemove,
allowDrag,
allowRemove,
}: {
field: string;
onRemove: (field: string) => void;
allowDrag: boolean;
allowRemove: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: field });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`fs-field-item ${allowDrag ? 'drag-enabled' : 'drag-disabled'}`}
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={14} />}
<span className="fs-field-key">{field}</span>
</div>
{allowRemove && (
<Button
className="remove-field-btn periscope-btn"
size="small"
onClick={(): void => onRemove(field)}
>
Remove
</Button>
)}
</div>
);
}
interface AddedFieldsProps {
inputValue: string;
fields: string[];
onFieldsChange: (fields: string[]) => void;
}
function AddedFields({
inputValue,
fields,
onFieldsChange,
}: AddedFieldsProps): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = fields.findIndex((f) => f === active.id);
const newIndex = fields.findIndex((f) => f === over.id);
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
}
};
const filteredFields = useMemo(
() =>
fields.filter((f) => f.toLowerCase().includes(inputValue.toLowerCase())),
[fields, inputValue],
);
const handleRemove = (field: string): void => {
onFieldsChange(fields.filter((f) => f !== field));
};
const allowDrag = inputValue.length === 0;
return (
<div className="fs-section fs-added">
<div className="fs-section-header">ADDED FIELDS</div>
<div className="fs-added-list">
<OverlayScrollbar>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
{filteredFields.length === 0 ? (
<div className="fs-no-values">No values found</div>
) : (
<SortableContext
items={fields}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>
{filteredFields.map((field) => (
<SortableField
key={field}
field={field}
onRemove={handleRemove}
allowDrag={allowDrag}
allowRemove={fields.length > 1}
/>
))}
</SortableContext>
)}
</DndContext>
</OverlayScrollbar>
</div>
</div>
);
}
export default AddedFields;

View File

@@ -0,0 +1,171 @@
.fields-settings {
display: flex;
flex-direction: column;
height: 100%;
background: var(--l1-background);
color: var(--l1-foreground);
overflow: hidden;
.fs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--l1-border);
.fs-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 500;
}
.fs-close-icon {
cursor: pointer;
color: var(--l2-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}
.fs-search {
.ant-input {
background-color: var(--l1-background);
height: 40px;
border-radius: 0;
border-left: none;
border-right: none;
}
}
.fs-section {
display: flex;
flex-direction: column;
&.fs-added {
max-height: 40%;
border-bottom: 1px solid var(--l1-border);
}
&.fs-other {
flex: 1;
min-height: 0;
}
}
.fs-section-header {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 8px 12px;
}
.fs-added-list {
overflow: hidden;
}
.fs-other-list {
flex: 1;
min-height: 0;
overflow: hidden;
.ant-skeleton-input {
width: 300px;
margin: 8px 12px;
}
}
.fs-no-values {
padding: 8px;
text-align: center;
color: var(--l2-foreground);
font-size: 12px;
}
.fs-limit-hint {
padding: 8px 12px;
text-align: center;
color: var(--muted-foreground);
font-size: 11px;
}
}
.fs-field-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-radius: 4px;
user-select: none;
font-size: 13px;
.drag-handle {
display: flex;
align-items: center;
gap: 8px;
flex-grow: 1;
}
.fs-field-key {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.drag-enabled {
cursor: grab;
&:active {
cursor: grabbing;
}
}
&.drag-disabled {
padding: 6px 12px;
}
&.other-field-item {
height: 32px;
}
.remove-field-btn,
.add-field-btn {
padding: 4px 10px;
opacity: 0;
transition: opacity 0.15s ease-in-out;
flex-shrink: 0;
}
&:hover {
background-color: var(--l2-background);
.remove-field-btn,
.add-field-btn {
opacity: 1;
}
}
}
.fs-footer {
display: flex;
gap: 12px;
padding: 12px;
border-top: 1px solid var(--l1-border);
button {
display: flex;
align-items: center;
justify-content: center;
width: 50%;
.ant-btn-icon {
margin: 3px !important;
}
}
}

View File

@@ -0,0 +1,138 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Input } from 'antd';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { CheckIcon, TableColumnsSplit, X, XIcon } from 'lucide-react';
import { DataSource } from 'types/common/queryBuilder';
import AddedFields from './AddedFields';
import OtherFields from './OtherFields';
import './FieldsSettings.styles.scss';
const MAX_FIELDS_DEFAULT = 10;
interface FieldsSettingsProps {
title: string;
// State is just string[] of keys for now. Can be extended to objects later if needed.
fields: string[];
onFieldsChange: (fields: string[]) => void;
onClose: () => void;
dataSource: DataSource;
maxFields?: number;
}
function FieldsSettings({
title,
fields,
onFieldsChange,
onClose,
dataSource,
maxFields = MAX_FIELDS_DEFAULT,
}: FieldsSettingsProps): JSX.Element {
// Local draft state — changes here don't persist until Save
const [draftFields, setDraftFields] = useState<string[]>(fields);
const [inputValue, setInputValue] = useState('');
const [debouncedInputValue, setDebouncedInputValue] = useState('');
const debouncedUpdate = useDebouncedFn((value) => {
setDebouncedInputValue(value as string);
}, 400);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
setInputValue(value);
debouncedUpdate(value);
},
[debouncedUpdate],
);
const handleAdd = useCallback(
(key: string): void => {
if (draftFields.length >= maxFields) {
return;
}
if (draftFields.includes(key)) {
return;
}
setDraftFields((prev) => [...prev, key]);
},
[draftFields, maxFields],
);
const handleSave = useCallback((): void => {
onFieldsChange(draftFields);
onClose();
}, [draftFields, onFieldsChange, onClose]);
const handleDiscard = useCallback((): void => {
setDraftFields(fields);
}, [fields]);
const hasUnsavedChanges = useMemo(
() =>
!(
draftFields.length === fields.length &&
draftFields.every((f, i) => f === fields[i])
),
[draftFields, fields],
);
const isAtLimit = draftFields.length >= maxFields;
return (
<div className="fields-settings">
<div className="fs-header">
<div className="fs-title">
<TableColumnsSplit size={16} />
{title}
</div>
<X className="fs-close-icon" size={16} onClick={onClose} />
</div>
<section className="fs-search">
<Input
type="text"
value={inputValue}
placeholder="Search for a field..."
onChange={handleInputChange}
/>
</section>
<AddedFields
inputValue={inputValue}
fields={draftFields}
onFieldsChange={setDraftFields}
/>
<OtherFields
dataSource={dataSource}
debouncedInputValue={debouncedInputValue}
addedFields={draftFields}
onAdd={handleAdd}
isAtLimit={isAtLimit}
/>
{hasUnsavedChanges && (
<div className="fs-footer">
<Button
type="default"
onClick={handleDiscard}
icon={<XIcon width={14} height={14} />}
>
Discard
</Button>
<Button
type="primary"
onClick={handleSave}
icon={<CheckIcon width={14} height={14} />}
>
Save changes
</Button>
</div>
)}
</div>
);
}
export default FieldsSettings;

View File

@@ -0,0 +1,95 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataSource } from 'types/common/queryBuilder';
interface OtherFieldsProps {
dataSource: DataSource;
debouncedInputValue: string;
addedFields: string[];
onAdd: (key: string) => void;
isAtLimit: boolean;
}
function OtherFields({
dataSource,
debouncedInputValue,
addedFields,
onAdd,
isAtLimit,
}: OtherFieldsProps): JSX.Element {
// API call to get available attribute keys
const { data, isFetching } = useGetAggregateKeys(
{
searchText: debouncedInputValue,
dataSource,
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [
REACT_QUERY_KEY.GET_OTHER_FILTERS,
'preview-fields',
debouncedInputValue,
],
enabled: true,
},
);
// Filter out already-added fields, match on .key from API response objects
const otherFields = useMemo(() => {
const attributes = data?.payload?.attributeKeys || [];
const addedSet = new Set(addedFields);
return attributes.filter((attr) => !addedSet.has(attr.key));
}, [data, addedFields]);
if (isFetching) {
return (
<div className="fs-section fs-other">
<div className="fs-section-header">OTHER FIELDS</div>
<div className="fs-other-list">
{Array.from({ length: 5 }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<Skeleton.Input active size="small" key={i} />
))}
</div>
</div>
);
}
return (
<div className="fs-section fs-other">
<div className="fs-section-header">OTHER FIELDS</div>
<div className="fs-other-list">
<OverlayScrollbar>
<>
{otherFields.length === 0 ? (
<div className="fs-no-values">No values found</div>
) : (
otherFields.map((attr) => (
<div key={attr.key} className="fs-field-item other-field-item">
<span className="fs-field-key">{attr.key}</span>
{!isAtLimit && (
<Button
className="add-field-btn periscope-btn"
size="small"
onClick={(): void => onAdd(attr.key)}
>
Add
</Button>
)}
</div>
))
)}
{isAtLimit && <div className="fs-limit-hint">Maximum 10 fields</div>}
</>
</OverlayScrollbar>
</div>
</div>
);
}
export default OtherFields;

View File

@@ -16,6 +16,7 @@ import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
@@ -213,6 +214,23 @@ function TraceDetailsV3(): JSX.Element {
],
);
const traceMetadataForHeader = useMemo(():
| TraceMetadataForHeader
| undefined => {
const payload = traceData?.payload;
if (!payload) {
return undefined;
}
const rootSpan = payload.spans?.find((s) => s.level === 0);
return {
startTimestampMillis: payload.startTimestampMillis,
endTimestampMillis: payload.endTimestampMillis,
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
};
}, [traceData?.payload]);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
@@ -250,7 +268,8 @@ function TraceDetailsV3(): JSX.Element {
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
noData={showNoData}
isDataLoaded={!isFetchingTraceData && !showNoData}
traceMetadata={traceMetadataForHeader}
/>
{showNoData ? (

View File

@@ -122,7 +122,7 @@ function PrettyView({
: String(context.fieldValue);
setCopy(text);
toast.success('Copied to clipboard', {
richColors: true,
richColors: false,
position: 'top-right',
});
},

View File

@@ -252,6 +252,7 @@ func (handler *handler) CreateChannel(rw http.ResponseWriter, req *http.Request)
return
}
// TODO: Move to PostableChannel and binding package
body, err := io.ReadAll(req.Body)
if err != nil {
render.Error(rw, err)

View File

@@ -49,7 +49,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
Tags: []string{"channels"},
Summary: "Create notification channel",
Description: "This endpoint creates a notification channel",
Request: new(alertmanagertypes.Receiver),
Request: new(alertmanagertypes.PostableChannel),
RequestContentType: "application/json",
Response: new(alertmanagertypes.Channel),
ResponseContentType: "application/json",

View File

@@ -44,6 +44,23 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/domains/{id}", handler.New(provider.authZ.AdminAccess(provider.authDomainHandler.Get), handler.OpenAPIDef{
ID: "GetAuthDomain",
Tags: []string{"authdomains"},
Summary: "Get auth domain by ID",
Description: "This endpoint returns an auth domain by ID",
Request: nil,
RequestContentType: "",
Response: new(authtypes.GettableAuthDomain),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/domains/{id}", handler.New(provider.authZ.AdminAccess(provider.authDomainHandler.Update), handler.OpenAPIDef{
ID: "UpdateAuthDomain",
Tags: []string{"authdomains"},

View File

@@ -39,6 +39,7 @@ type Module interface {
type Handler interface {
List(http.ResponseWriter, *http.Request)
Get(http.ResponseWriter, *http.Request)
Create(http.ResponseWriter, *http.Request)
Update(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)

View File

@@ -77,6 +77,31 @@ func (handler *handler) Delete(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) Get(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
domainID, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, err)
return
}
authDomain, err := handler.module.GetByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), domainID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, authtypes.NewGettableAuthDomainFromAuthDomain(authDomain, handler.module.GetAuthNProviderInfo(ctx, authDomain)))
}
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

View File

@@ -4,12 +4,14 @@ import (
"encoding/json"
"reflect"
"regexp"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
"github.com/swaggest/jsonschema-go"
"github.com/uptrace/bun"
)
@@ -28,6 +30,18 @@ type Channels = []*Channel
type GettableChannels = []*Channel
// TODO: the oneOf emitted by JSONSchema is not the shape OpenAPI wants for a
// discriminated union. OpenAPI's discriminator requires every oneOf branch to
// be a $ref to a named component and a sibling property whose value selects
// the variant. Our payload instead uses the *presence* of one of the 18
// *_configs arrays to imply the type, so no discriminator can be attached.
// Refactor PostableChannel into a {name, type, config} envelope (see
// ruletypes.RuleThresholdData for the pattern) so each notification kind
// becomes a named component and the discriminator can be wired up properly.
type PostableChannel struct {
Receiver
}
// Channel represents a single receiver of the alertmanager config.
type Channel struct {
bun.BaseModel `bun:"table:notification_channel"`
@@ -183,3 +197,30 @@ func (c *Channel) Update(receiver Receiver) error {
return nil
}
func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
type alias PostableChannel
reflector := &jsonschema.Reflector{}
schema, err := reflector.Reflect(alias{}, jsonschema.DefinitionsPrefix("#/components/schemas/"))
if err != nil {
return jsonschema.Schema{}, err
}
schema.WithRequired("name")
var oneOf []jsonschema.SchemaOrBool
receiverType := reflect.TypeOf(Receiver{})
for i := 0; i < receiverType.NumField(); i++ {
jsonTag := strings.Split(receiverType.Field(i).Tag.Get("json"), ",")[0]
if !strings.HasSuffix(jsonTag, "_configs") {
continue
}
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
oneOf = append(oneOf, branch.ToSchemaOrBool())
}
schema.WithOneOf(oneOf...)
return schema, nil
}

View File

@@ -4,10 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/prometheus/common/model"
"log/slog"
"time"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"

View File

@@ -156,6 +156,15 @@ func (typ *Identity) ToClaims() Claims {
}
}
func (AuthNProvider) Enum() []any {
return []any{
AuthNProviderGoogleAuth,
AuthNProviderSAML,
AuthNProviderEmailPassword,
AuthNProviderOIDC,
}
}
type AuthNStore interface {
// Get user and factor password by email and orgID.
GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, []*UserRole, error)

View File

@@ -29,8 +29,8 @@ var (
)
type GettableAuthDomain struct {
*StorableAuthDomain
*AuthDomainConfig
StorableAuthDomain
AuthDomainConfig
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
@@ -57,6 +57,15 @@ type StorableAuthDomain struct {
types.TimeAuditable
}
// TODO: the oneOf emitted by JSONSchemaOneOf is not the shape OpenAPI wants
// for a discriminated union. OpenAPI's discriminator requires every oneOf
// branch to be a $ref to a named component and a sibling property whose value
// selects the variant. ssoType is already discriminator-shaped, but the
// variant payload lives in a sibling field (samlConfig / googleAuthConfig /
// oidcConfig) instead of being the payload itself, so no discriminator can
// be attached. Refactor AuthDomainConfig into an envelope (see
// ruletypes.RuleThresholdData for the pattern) where the chosen config is
// the payload and ssoType is the discriminator.
type AuthDomainConfig struct {
SSOEnabled bool `json:"ssoEnabled"`
AuthNProvider AuthNProvider `json:"ssoType"`
@@ -111,8 +120,8 @@ func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain)
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
return &GettableAuthDomain{
StorableAuthDomain: authDomain.StorableAuthDomain(),
AuthDomainConfig: authDomain.AuthDomainConfig(),
StorableAuthDomain: *authDomain.StorableAuthDomain(),
AuthDomainConfig: *authDomain.AuthDomainConfig(),
AuthNProviderInfo: authNProviderInfo,
}
}
@@ -186,6 +195,14 @@ func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
}
func (AuthDomainConfig) JSONSchemaOneOf() []any {
return []any{
SamlConfig{},
GoogleConfig{},
OIDCConfig{},
}
}
type AuthDomainStore interface {
// Get by id.
Get(context.Context, valuer.UUID) (*AuthDomain, error)

View File

@@ -63,15 +63,15 @@ type StorablePlannedMaintenance struct {
}
type PlannedMaintenance struct {
ID valuer.UUID `json:"id" required:"true"`
Name string `json:"name" required:"true"`
Description string `json:"description"`
Schedule *Schedule `json:"schedule" required:"true"`
RuleIDs []string `json:"alertIds"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy"`
ID valuer.UUID `json:"id" required:"true"`
Name string `json:"name" required:"true"`
Description string `json:"description"`
Schedule *Schedule `json:"schedule" required:"true"`
RuleIDs []string `json:"alertIds"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy"`
Status MaintenanceStatus `json:"status" required:"true"`
Kind MaintenanceKind `json:"kind" required:"true"`
}

View File

@@ -3,8 +3,10 @@ package ruletypes
import (
"database/sql/driver"
"encoding/json"
"reflect"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -73,10 +75,16 @@ type Recurrence struct {
}
func (r *Recurrence) Scan(src interface{}) error {
if data, ok := src.([]byte); ok {
switch data := src.(type) {
case []byte:
return json.Unmarshal(data, r)
case string:
return json.Unmarshal([]byte(data), r)
case nil:
return nil
default:
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "recurrence: (unsupported \"%s\")", reflect.TypeOf(data).String())
}
return nil
}
func (r *Recurrence) Value() (driver.Value, error) {

View File

@@ -3,7 +3,10 @@ package ruletypes
import (
"database/sql/driver"
"encoding/json"
"reflect"
"time"
"github.com/SigNoz/signoz/pkg/errors"
)
type Schedule struct {
@@ -14,10 +17,16 @@ type Schedule struct {
}
func (s *Schedule) Scan(src interface{}) error {
if data, ok := src.([]byte); ok {
switch data := src.(type) {
case []byte:
return json.Unmarshal(data, s)
case string:
return json.Unmarshal([]byte(data), s)
case nil:
return nil
default:
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "schedule: (unsupported \"%s\")", reflect.TypeOf(data).String())
}
return nil
}
func (s *Schedule) Value() (driver.Value, error) {