Compare commits

...

11 Commits

Author SHA1 Message Date
grandwizard28
ed8917f8e6 fix(ruletypes): mark threshold variant kind/spec required 2026-05-05 06:04:08 +05:30
grandwizard28
43b7fea253 style(ruletypes): add blank lines in PrepareJSONSchema 2026-05-05 05:59:01 +05:30
grandwizard28
d0080abc8c style(openapi): add blank lines between logical blocks in attachDiscriminators 2026-05-05 05:57:31 +05:30
grandwizard28
0bb29f9e7f refactor(ruletypes): strip envelope parent properties in attachDiscriminators
Revert the json:"-" + custom MarshalJSON dance on the envelope
structs. Restore the original tags (json:"kind" / json:"spec"),
keep the discriminator marker, and clear the parent's redundant
properties / required block in attachDiscriminators after the
discriminator is promoted.
2026-05-05 05:47:24 +05:30
grandwizard28
d2130b4d44 fix(ruletypes): keep envelope kind/spec out of the parent schema via json:"-" 2026-05-05 05:43:15 +05:30
grandwizard28
ff36066b07 fix(ruletypes): mark evaluation variant kind/spec required 2026-05-05 05:37:33 +05:30
grandwizard28
e78a111494 chore(ruletypes): trim discriminator comments 2026-05-05 05:34:55 +05:30
grandwizard28
f1af8f242c refactor(openapi): inline discriminator constant and tighten attachDiscriminators 2026-05-05 05:32:48 +05:30
grandwizard28
b4412c02b6 feat(ruletypes): publish OpenAPI 3 discriminator on RuleThresholdData and EvaluationEnvelope
Both types model `{kind, spec}` discriminated unions on the wire but
the generated OpenAPI lacked the `discriminator:` keyword, so
codegen tools (oapi-codegen, terraform-plugin-codegen-openapi) fell
back to opaque `Spec: any` and consumers had to hand-write
JSON-bridges instead of typed Expand/Flatten.

Add the discriminator declaration via a marker convention:

- Each parent type implements `jsonschema.Preparer` to set an
  `x-signoz-discriminator` extra property carrying `propertyName` and
  the per-kind `mapping`.
- A small `attachDiscriminators` pass in pkg/signoz/openapi.go runs
  after spec reflection, walks every component schema, promotes the
  marker into a real openapi3.Discriminator, and removes the marker
  so it doesn't leak into the rendered YAML.

The two-step is required because jsonschema-go.Schema has no
Discriminator field of its own and openapi-go only carries through
`x-`-prefixed extras unchanged. The wire shape is unchanged —
`{kind: "<value>", spec: <variant>}` is still what's sent and
received.

Adding a new variant: append to JSONSchemaOneOf and add a
mapping entry on PrepareJSONSchema.
2026-05-05 05:22:23 +05:30
Pandey
8409a9798d fix(authdomain): nest config response, rename Updateable→Updatable, return Identifiable on create (#11176)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(authdomain): nest config response, rename Updateable→Updatable, return Identifiable on create

Three small API-shape corrections on auth_domain:

- GettableAuthDomain previously embedded AuthDomainConfig, which
  flattened sso_enabled / saml_config / oidc_config / google_auth_config /
  role_mapping at the response root and made the response shape
  diverge from the request shape (PostableAuthDomain has them under
  `config`). Move it under a named `Config` field with a `config`
  json tag so request and response carry the same nested object.
- UpdateableAuthDomain → UpdatableAuthDomain (typo fix; aligns with
  UpdatableUser already in the codebase).
- CreateAuthDomain previously returned the full GettableAuthDomain;
  the only field clients actually need from the create response is
  the new ID. Switch to Identifiable so the contract states what the
  endpoint guarantees and clients re-Read for the full domain when
  needed.

Frontend schema and OpenAPI spec regenerated.

* fix(authdomain-frontend): adapt to nested config + Identifiable create response

Regenerate the orval client (`yarn generate:api`) and update the
auth-domain UI for the API shape changes from the previous commit:

- `record.ssoType`, `.ssoEnabled`, `.googleAuthConfig`, `.oidcConfig`,
  `.samlConfig`, `.roleMapping` are now nested under `record.config.*`
  in `AuthtypesGettableAuthDomainDTO` — update SSOEnforcementToggle,
  CreateEdit form initial-values, the list page's Configure button,
  and the auth-domain test mocks.
- `mockCreateSuccessResponse` now returns `{ id }` (Identifiable)
  instead of the full domain.

`yarn generate:api` ran clean: lint OK, tsgo OK.

* fix(authdomain): align CreateAuthDomain success code with handler + adjust integration test

The Create handler returns http.StatusCreated but the OpenAPI
annotation said StatusOK. Sync the annotation to 201, regenerate the
spec + frontend client.

The callbackauthn integration test (01_domain.py) still read
`domain["ssoType"]` off the GET response — now nested under
`domain["config"]["ssoType"]` after the previous shape change. Update
the assertion.
2026-05-04 20:44:41 +00:00
Vinicius Lourenço
de6e4890ae feat(query-search-v2): add initial expression support & store to manage (#11062)
* feat(query-search-v2): add initial expression support & store to manage

* fix(qbv2): format issue
2026-05-04 16:22:52 +00:00
27 changed files with 894 additions and 310 deletions

View File

@@ -301,34 +301,20 @@ components:
type: string
type: object
AuthtypesGettableAuthDomain:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
authNProviderInfo:
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
createdAt:
format: date-time
type: string
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
id:
type: string
name:
type: string
oidcConfig:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
orgId:
type: string
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
ssoEnabled:
type: boolean
ssoType:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
updatedAt:
format: date-time
type: string
@@ -589,7 +575,7 @@ components:
- relation
- object
type: object
AuthtypesUpdateableAuthDomain:
AuthtypesUpdatableAuthDomain:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
@@ -4362,19 +4348,20 @@ components:
$ref: '#/components/schemas/RuletypesEvaluationKind'
spec:
$ref: '#/components/schemas/RuletypesCumulativeWindow'
type: object
RuletypesEvaluationEnvelope:
oneOf:
- $ref: '#/components/schemas/RuletypesEvaluationRolling'
- $ref: '#/components/schemas/RuletypesEvaluationCumulative'
properties:
kind:
$ref: '#/components/schemas/RuletypesEvaluationKind'
spec: {}
required:
- kind
- spec
type: object
RuletypesEvaluationEnvelope:
discriminator:
mapping:
cumulative: '#/components/schemas/RuletypesEvaluationCumulative'
rolling: '#/components/schemas/RuletypesEvaluationRolling'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/RuletypesEvaluationRolling'
- $ref: '#/components/schemas/RuletypesEvaluationCumulative'
type: object
RuletypesEvaluationKind:
enum:
- rolling
@@ -4386,6 +4373,9 @@ components:
$ref: '#/components/schemas/RuletypesEvaluationKind'
spec:
$ref: '#/components/schemas/RuletypesRollingWindow'
required:
- kind
- spec
type: object
RuletypesGettableTestRule:
properties:
@@ -4693,15 +4683,12 @@ components:
- compositeQuery
type: object
RuletypesRuleThresholdData:
discriminator:
mapping:
basic: '#/components/schemas/RuletypesThresholdBasic'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/RuletypesThresholdBasic'
properties:
kind:
$ref: '#/components/schemas/RuletypesThresholdKind'
spec: {}
required:
- kind
- spec
type: object
RuletypesRuleType:
enum:
@@ -4743,6 +4730,9 @@ components:
$ref: '#/components/schemas/RuletypesThresholdKind'
spec:
$ref: '#/components/schemas/RuletypesBasicRuleThresholds'
required:
- kind
- spec
type: object
RuletypesThresholdKind:
enum:
@@ -7079,20 +7069,20 @@ paths:
schema:
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
responses:
"200":
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: OK
description: Created
"400":
content:
application/json:
@@ -7248,7 +7238,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdateableAuthDomain'
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
responses:
"204":
description: No Content

View File

@@ -19,8 +19,8 @@ import type {
import type {
AuthtypesPostableAuthDomainDTO,
AuthtypesUpdateableAuthDomainDTO,
CreateAuthDomain200,
AuthtypesUpdatableAuthDomainDTO,
CreateAuthDomain201,
DeleteAuthDomainPathParameters,
GetAuthDomain200,
GetAuthDomainPathParameters,
@@ -126,7 +126,7 @@ export const createAuthDomain = (
authtypesPostableAuthDomainDTO: BodyType<AuthtypesPostableAuthDomainDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAuthDomain200>({
return GeneratedAPIInstance<CreateAuthDomain201>({
url: `/api/v1/domains`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -388,13 +388,13 @@ export const invalidateGetAuthDomain = async (
*/
export const updateAuthDomain = (
{ id }: UpdateAuthDomainPathParameters,
authtypesUpdateableAuthDomainDTO: BodyType<AuthtypesUpdateableAuthDomainDTO>,
authtypesUpdatableAuthDomainDTO: BodyType<AuthtypesUpdatableAuthDomainDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/domains/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdateableAuthDomainDTO,
data: authtypesUpdatableAuthDomainDTO,
});
};
@@ -407,7 +407,7 @@ export const getUpdateAuthDomainMutationOptions = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
>;
@@ -416,7 +416,7 @@ export const getUpdateAuthDomainMutationOptions = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
> => {
@@ -433,7 +433,7 @@ export const getUpdateAuthDomainMutationOptions = <
Awaited<ReturnType<typeof updateAuthDomain>>,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -448,7 +448,7 @@ export type UpdateAuthDomainMutationResult = NonNullable<
Awaited<ReturnType<typeof updateAuthDomain>>
>;
export type UpdateAuthDomainMutationBody =
BodyType<AuthtypesUpdateableAuthDomainDTO>;
BodyType<AuthtypesUpdatableAuthDomainDTO>;
export type UpdateAuthDomainMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -463,7 +463,7 @@ export const useUpdateAuthDomain = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
>;
@@ -472,7 +472,7 @@ export const useUpdateAuthDomain = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
> => {

View File

@@ -1641,109 +1641,32 @@ export interface AuthtypesCallbackAuthNSupportDTO {
url?: string;
}
export type AuthtypesGettableAuthDomainDTO =
| (AuthtypesSamlConfigDTO & {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
googleAuthConfig?: AuthtypesGoogleConfigDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
oidcConfig?: AuthtypesOIDCConfigDTO;
/**
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
})
| (AuthtypesGoogleConfigDTO & {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
googleAuthConfig?: AuthtypesGoogleConfigDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
oidcConfig?: AuthtypesOIDCConfigDTO;
/**
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
})
| (AuthtypesOIDCConfigDTO & {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
googleAuthConfig?: AuthtypesGoogleConfigDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
oidcConfig?: AuthtypesOIDCConfigDTO;
/**
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
});
export interface AuthtypesGettableAuthDomainDTO {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
config?: AuthtypesAuthDomainConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
orgId?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
}
export interface AuthtypesGettableObjectsDTO {
resource: AuthtypesResourceDTO;
@@ -2067,7 +1990,7 @@ export interface AuthtypesTransactionDTO {
relation: string;
}
export interface AuthtypesUpdateableAuthDomainDTO {
export interface AuthtypesUpdatableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
}
@@ -6655,28 +6578,36 @@ export interface RuletypesCumulativeWindowDTO {
timezone: string;
}
export enum RuletypesEvaluationCumulativeDTOKind {
cumulative = 'cumulative',
}
export interface RuletypesEvaluationCumulativeDTO {
kind?: RuletypesEvaluationKindDTO;
spec?: RuletypesCumulativeWindowDTO;
/**
* @type string
* @enum cumulative
*/
kind: RuletypesEvaluationCumulativeDTOKind;
spec: RuletypesCumulativeWindowDTO;
}
export type RuletypesEvaluationEnvelopeDTO =
| (RuletypesEvaluationRollingDTO & {
kind: RuletypesEvaluationKindDTO;
spec: unknown;
})
| (RuletypesEvaluationCumulativeDTO & {
kind: RuletypesEvaluationKindDTO;
spec: unknown;
});
| RuletypesEvaluationRollingDTO
| RuletypesEvaluationCumulativeDTO;
export enum RuletypesEvaluationKindDTO {
rolling = 'rolling',
cumulative = 'cumulative',
}
export enum RuletypesEvaluationRollingDTOKind {
rolling = 'rolling',
}
export interface RuletypesEvaluationRollingDTO {
kind?: RuletypesEvaluationKindDTO;
spec?: RuletypesRollingWindowDTO;
/**
* @type string
* @enum rolling
*/
kind: RuletypesEvaluationRollingDTOKind;
spec: RuletypesRollingWindowDTO;
}
export interface RuletypesGettableTestRuleDTO {
@@ -7031,10 +6962,7 @@ export interface RuletypesRuleConditionDTO {
thresholds?: RuletypesRuleThresholdDataDTO;
}
export type RuletypesRuleThresholdDataDTO = RuletypesThresholdBasicDTO & {
kind: RuletypesThresholdKindDTO;
spec: unknown;
};
export type RuletypesRuleThresholdDataDTO = RuletypesThresholdBasicDTO;
export enum RuletypesRuleTypeDTO {
threshold_rule = 'threshold_rule',
@@ -7070,9 +6998,16 @@ export enum RuletypesSeasonalityDTO {
daily = 'daily',
weekly = 'weekly',
}
export enum RuletypesThresholdBasicDTOKind {
basic = 'basic',
}
export interface RuletypesThresholdBasicDTO {
kind?: RuletypesThresholdKindDTO;
spec?: RuletypesBasicRuleThresholdsDTO;
/**
* @type string
* @enum basic
*/
kind: RuletypesThresholdBasicDTOKind;
spec: RuletypesBasicRuleThresholdsDTO;
}
export enum RuletypesThresholdKindDTO {
@@ -8432,8 +8367,8 @@ export type ListAuthDomains200 = {
status: string;
};
export type CreateAuthDomain200 = {
data: AuthtypesGettableAuthDomainDTO;
export type CreateAuthDomain201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/

View File

@@ -0,0 +1,126 @@
import { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import { parseAsString, useQueryState } from 'nuqs';
import { useStore } from 'zustand';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
import { QuerySearchV2Context } from './context';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
import { createExpressionStore } from './QuerySearchV2.store';
export interface QuerySearchV2ProviderProps {
queryParamKey: string;
initialExpression?: string;
/**
* @default false
*/
persistOnUnmount?: boolean;
children: ReactNode;
}
/**
* Provider component that creates a scoped zustand store and exposes
* expression state to children via context.
*/
export function QuerySearchV2Provider({
initialExpression = '',
persistOnUnmount = false,
queryParamKey,
children,
}: QuerySearchV2ProviderProps): JSX.Element {
const storeRef = useRef(createExpressionStore());
const store = storeRef.current;
const [urlExpression, setUrlExpression] = useQueryState(
queryParamKey,
parseAsString,
);
const committedExpression = useStore(store, (s) => s.committedExpression);
const setInputExpression = useStore(store, (s) => s.setInputExpression);
const commitExpression = useStore(store, (s) => s.commitExpression);
const initializeFromUrl = useStore(store, (s) => s.initializeFromUrl);
const resetExpression = useStore(store, (s) => s.resetExpression);
const isInitialized = useRef(false);
useEffect(() => {
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, initializeFromUrl]);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {
if (!persistOnUnmount) {
setUrlExpression(null);
resetExpression();
}
};
}, [persistOnUnmount, setUrlExpression, resetExpression]);
const handleChange = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
setInputExpression(userOnly);
},
[initialExpression, setInputExpression],
);
const handleRun = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
commitExpression(userOnly);
},
[initialExpression, commitExpression],
);
const combinedExpression = useMemo(
() => combineInitialAndUserExpression(initialExpression, committedExpression),
[initialExpression, committedExpression],
);
const contextValue = useMemo<QuerySearchV2ContextValue>(
() => ({
expression: combinedExpression,
userExpression: committedExpression,
initialExpression,
querySearchProps: {
initialExpression: initialExpression.trim() ? initialExpression : undefined,
onChange: handleChange,
onRun: handleRun,
},
}),
[
combinedExpression,
committedExpression,
initialExpression,
handleChange,
handleRun,
],
);
return (
<QuerySearchV2Context.Provider value={contextValue}>
{children}
</QuerySearchV2Context.Provider>
);
}

View File

@@ -0,0 +1,60 @@
import { createStore, StoreApi } from 'zustand';
export type QuerySearchV2Store = {
/**
* User-typed expression (local state, updates on typing)
*/
inputExpression: string;
/**
* Committed expression (synced to URL, updates on submit)
*/
committedExpression: string;
setInputExpression: (expression: string) => void;
commitExpression: (expression: string) => void;
resetExpression: () => void;
initializeFromUrl: (urlExpression: string) => void;
};
export interface QuerySearchProps {
initialExpression: string | undefined;
onChange: (expression: string) => void;
onRun: (expression: string) => void;
}
export interface QuerySearchV2ContextValue {
/**
* Combined expression: "initialExpression AND (userExpression)"
*/
expression: string;
userExpression: string;
initialExpression: string;
querySearchProps: QuerySearchProps;
}
export function createExpressionStore(): StoreApi<QuerySearchV2Store> {
return createStore<QuerySearchV2Store>((set) => ({
inputExpression: '',
committedExpression: '',
setInputExpression: (expression: string): void => {
set({ inputExpression: expression });
},
commitExpression: (expression: string): void => {
set({
inputExpression: expression,
committedExpression: expression,
});
},
resetExpression: (): void => {
set({
inputExpression: '',
committedExpression: '',
});
},
initializeFromUrl: (urlExpression: string): void => {
set({
inputExpression: urlExpression,
committedExpression: urlExpression,
});
},
}));
}

View File

@@ -0,0 +1,95 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { useQuerySearchV2Context } from '../context';
import {
QuerySearchV2Provider,
QuerySearchV2ProviderProps,
} from '../QuerySearchV2.provider';
const mockSetQueryState = jest.fn();
let mockUrlValue: string | null = null;
jest.mock('nuqs', () => ({
parseAsString: {},
useQueryState: jest.fn(() => [mockUrlValue, mockSetQueryState]),
}));
function createWrapper(
props: Partial<QuerySearchV2ProviderProps> = {},
): ({ children }: { children: ReactNode }) => JSX.Element {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QuerySearchV2Provider queryParamKey="testExpression" {...props}>
{children}
</QuerySearchV2Provider>
);
};
}
describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlValue = null;
});
it('should provide initial context values', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
expect(result.current.expression).toBe('');
expect(result.current.userExpression).toBe('');
expect(result.current.initialExpression).toBe('');
});
it('should combine initialExpression with userExpression', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'k8s.pod.name = "my-pod"' }),
});
expect(result.current.expression).toBe('k8s.pod.name = "my-pod"');
expect(result.current.initialExpression).toBe('k8s.pod.name = "my-pod"');
act(() => {
result.current.querySearchProps.onChange('service = "api"');
});
act(() => {
result.current.querySearchProps.onRun('service = "api"');
});
expect(result.current.expression).toBe(
'k8s.pod.name = "my-pod" AND (service = "api")',
);
expect(result.current.userExpression).toBe('service = "api"');
});
it('should provide querySearchProps with correct callbacks', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'initial' }),
});
expect(result.current.querySearchProps.initialExpression).toBe('initial');
expect(typeof result.current.querySearchProps.onChange).toBe('function');
expect(typeof result.current.querySearchProps.onRun).toBe('function');
});
it('should initialize from URL value on mount', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
expect(result.current.userExpression).toBe('status = 500');
expect(result.current.expression).toBe('status = 500');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useQuerySearchV2Context());
}).toThrow(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
});
});

View File

@@ -0,0 +1,61 @@
import { createExpressionStore } from '../QuerySearchV2.store';
describe('createExpressionStore', () => {
it('should create a store with initial state', () => {
const store = createExpressionStore();
const state = store.getState();
expect(state.inputExpression).toBe('');
expect(state.committedExpression).toBe('');
});
it('should update inputExpression via setInputExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
expect(store.getState().inputExpression).toBe('service.name = "api"');
expect(store.getState().committedExpression).toBe('');
});
it('should update both expressions via commitExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
store.getState().commitExpression('service.name = "api"');
expect(store.getState().inputExpression).toBe('service.name = "api"');
expect(store.getState().committedExpression).toBe('service.name = "api"');
});
it('should reset all state via resetExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
store.getState().commitExpression('service.name = "api"');
store.getState().resetExpression();
expect(store.getState().inputExpression).toBe('');
expect(store.getState().committedExpression).toBe('');
});
it('should initialize from URL value', () => {
const store = createExpressionStore();
store.getState().initializeFromUrl('status = 500');
expect(store.getState().inputExpression).toBe('status = 500');
expect(store.getState().committedExpression).toBe('status = 500');
});
it('should create isolated store instances', () => {
const store1 = createExpressionStore();
const store2 = createExpressionStore();
store1.getState().setInputExpression('expr1');
store2.getState().setInputExpression('expr2');
expect(store1.getState().inputExpression).toBe('expr1');
expect(store2.getState().inputExpression).toBe('expr2');
});
});

View File

@@ -0,0 +1,17 @@
// eslint-disable-next-line no-restricted-imports -- React Context required for scoped store pattern
import { createContext, useContext } from 'react';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
export const QuerySearchV2Context =
createContext<QuerySearchV2ContextValue | null>(null);
export function useQuerySearchV2Context(): QuerySearchV2ContextValue {
const context = useContext(QuerySearchV2Context);
if (!context) {
throw new Error(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
}
return context;
}

View File

@@ -0,0 +1,8 @@
export { useQuerySearchV2Context } from './context';
export type { QuerySearchV2ProviderProps } from './QuerySearchV2.provider';
export { QuerySearchV2Provider } from './QuerySearchV2.provider';
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2Store,
} from './QuerySearchV2.store';

View File

@@ -19,6 +19,13 @@
display: flex;
flex-direction: row;
.query-search-initial-scope-label {
position: absolute;
left: 8px;
top: 10px;
z-index: 10;
}
.query-where-clause-editor {
flex: 1;
min-width: 400px;
@@ -53,6 +60,10 @@
}
}
}
&.hasInitialExpression .cm-editor .cm-content {
padding-left: 22px !important;
}
}
.cm-editor {
@@ -68,7 +79,6 @@
border-radius: 2px;
border: 1px solid var(--l1-border);
padding: 0px !important;
background-color: var(--l1-background) !important;
&:focus-within {
border-color: var(--l1-border);

View File

@@ -30,7 +30,7 @@ import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariabl
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
import { Info, TriangleAlert } from 'lucide-react';
import { Filter, Info, TriangleAlert } from 'lucide-react';
import {
IDetailedError,
IQueryContext,
@@ -47,6 +47,7 @@ import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { queryExamples } from './constants';
import { combineInitialAndUserExpression } from './utils';
import './QuerySearch.styles.scss';
@@ -85,6 +86,8 @@ interface QuerySearchProps {
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
showFilterSuggestionsWithoutMetric?: boolean;
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
initialExpression?: string;
}
function QuerySearch({
@@ -96,6 +99,7 @@ function QuerySearch({
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
initialExpression,
}: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -112,18 +116,26 @@ function QuerySearch({
const [isFocused, setIsFocused] = useState(false);
const editorRef = useRef<EditorView | null>(null);
const handleQueryValidation = useCallback((newExpression: string): void => {
try {
const validationResponse = validateQuery(newExpression);
setValidation(validationResponse);
} catch (error) {
setValidation({
isValid: false,
message: 'Failed to process query',
errors: [error as IDetailedError],
});
}
}, []);
const isScopedFilter = initialExpression !== undefined;
const validateExpressionForEditor = useCallback(
(editorDoc: string): void => {
const toValidate = isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', editorDoc)
: editorDoc;
try {
const validationResponse = validateQuery(toValidate);
setValidation(validationResponse);
} catch (error) {
setValidation({
isValid: false,
message: 'Failed to process query',
errors: [error as IDetailedError],
});
}
},
[initialExpression, isScopedFilter],
);
const getCurrentExpression = useCallback(
(): string => editorRef.current?.state.doc.toString() || '',
@@ -165,6 +177,8 @@ function QuerySearch({
setIsEditorReady(true);
}, []);
const prevQueryDataExpressionRef = useRef<string | undefined>();
useEffect(
() => {
if (!isEditorReady) {
@@ -173,13 +187,22 @@ function QuerySearch({
const newExpression = queryData.filter?.expression || '';
const currentExpression = getCurrentExpression();
const prevExpression = prevQueryDataExpressionRef.current;
// Do not update codemirror editor if the expression is the same
if (newExpression !== currentExpression && !isFocused) {
// Only sync editor when queryData.filter?.expression actually changed from external source
// Not when focus changed (which would reset uncommitted user input)
const queryDataExpressionChanged = prevExpression !== newExpression;
prevQueryDataExpressionRef.current = newExpression;
if (
queryDataExpressionChanged &&
newExpression !== currentExpression &&
!isFocused
) {
updateEditorValue(newExpression, { skipOnChange: true });
if (newExpression) {
handleQueryValidation(newExpression);
}
}
if (!isFocused) {
validateExpressionForEditor(currentExpression);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -284,7 +307,7 @@ function QuerySearch({
}
});
}
setKeySuggestions(Array.from(merged.values()));
setKeySuggestions([...merged.values()]);
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
@@ -337,7 +360,7 @@ function QuerySearch({
// If value contains single quotes, escape them and wrap in single quotes
if (value.includes("'")) {
// Replace single quotes with escaped single quotes
const escapedValue = value.replace(/'/g, "\\'");
const escapedValue = value.replaceAll(/'/g, "\\'");
return `'${escapedValue}'`;
}
@@ -614,7 +637,7 @@ function QuerySearch({
const handleBlur = (): void => {
const currentExpression = getCurrentExpression();
handleQueryValidation(currentExpression);
validateExpressionForEditor(currentExpression);
setIsFocused(false);
};
@@ -632,7 +655,6 @@ function QuerySearch({
);
const handleExampleClick = (exampleQuery: string): void => {
// If there's an existing query, append the example with AND
const currentExpression = getCurrentExpression();
const newExpression = currentExpression
? `${currentExpression} AND ${exampleQuery}`
@@ -897,12 +919,12 @@ function QuerySearch({
// If we have previous pairs, we can prioritize keys that haven't been used yet
if (queryContext.queryPairs && queryContext.queryPairs.length > 0) {
const usedKeys = queryContext.queryPairs.map((pair) => pair.key);
const usedKeys = new Set(queryContext.queryPairs.map((pair) => pair.key));
// Add boost to unused keys to prioritize them
options = options.map((option) => ({
...option,
boost: usedKeys.includes(option.label) ? -10 : 10,
boost: usedKeys.has(option.label) ? -10 : 10,
}));
}
@@ -1317,6 +1339,19 @@ function QuerySearch({
)}
<div className="query-where-clause-editor-container">
{isScopedFilter ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</div>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
@@ -1356,6 +1391,7 @@ function QuerySearch({
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter,
})}
extensions={[
autocompletion({
@@ -1390,7 +1426,12 @@ function QuerySearch({
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(getCurrentExpression());
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
}
return true;
},
@@ -1555,6 +1596,7 @@ QuerySearch.defaultProps = {
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
initialExpression: undefined,
};
export default QuerySearch;

View File

@@ -0,0 +1,58 @@
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
describe('entityLogsExpression', () => {
describe('combineInitialAndUserExpression', () => {
it('returns user when initial is empty', () => {
expect(combineInitialAndUserExpression('', 'body contains error')).toBe(
'body contains error',
);
});
it('returns initial when user is empty', () => {
expect(combineInitialAndUserExpression('k8s.pod.name = "x"', '')).toBe(
'k8s.pod.name = "x"',
);
});
it('wraps user in parentheses with AND', () => {
expect(
combineInitialAndUserExpression('k8s.pod.name = "x"', 'body = "a"'),
).toBe('k8s.pod.name = "x" AND (body = "a")');
});
});
describe('getUserExpressionFromCombined', () => {
it('returns empty when combined equals initial', () => {
expect(
getUserExpressionFromCombined('k8s.pod.name = "x"', 'k8s.pod.name = "x"'),
).toBe('');
});
it('extracts user from wrapped form', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND (body = "a")',
),
).toBe('body = "a"');
});
it('extracts user from legacy AND without parens', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND body = "a"',
),
).toBe('body = "a"');
});
it('returns full combined when initial is empty', () => {
expect(getUserExpressionFromCombined('', 'service.name = "a"')).toBe(
'service.name = "a"',
);
});
});
});

View File

@@ -0,0 +1,40 @@
export function combineInitialAndUserExpression(
initial: string,
user: string,
): string {
const i = initial.trim();
const u = user.trim();
if (!i) {
return u;
}
if (!u) {
return i;
}
return `${i} AND (${u})`;
}
export function getUserExpressionFromCombined(
initial: string,
combined: string | null | undefined,
): string {
const i = initial.trim();
const c = (combined ?? '').trim();
if (!c) {
return '';
}
if (!i) {
return c;
}
if (c === i) {
return '';
}
const wrappedPrefix = `${i} AND (`;
if (c.startsWith(wrappedPrefix) && c.endsWith(')')) {
return c.slice(wrappedPrefix.length, -1);
}
const plainPrefix = `${i} AND `;
if (c.startsWith(plainPrefix)) {
return c.slice(plainPrefix.length);
}
return c;
}

View File

@@ -0,0 +1,14 @@
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2ProviderProps,
} from './QueryV2/QuerySearch/Provider';
export {
QuerySearchV2Provider,
useQuerySearchV2Context,
} from './QueryV2/QuerySearch/Provider';
export { QueryBuilderV2 } from './QueryBuilderV2';
export {
QueryBuilderV2Provider,
useQueryBuilderV2Context,
} from './QueryBuilderV2Context';

View File

@@ -60,7 +60,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const [form] = Form.useForm<FormValues>();
const [authnProvider, setAuthnProvider] = useState<
AuthtypesAuthNProviderDTO | ''
>(record?.ssoType || '');
>(record?.config?.ssoType || '');
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();

View File

@@ -112,21 +112,26 @@ export function prepareInitialValues(
};
}
const config = record.config ?? {};
return {
...record,
googleAuthConfig: record.googleAuthConfig
name: record.name,
ssoEnabled: config.ssoEnabled,
ssoType: config.ssoType,
samlConfig: config.samlConfig ?? undefined,
oidcConfig: config.oidcConfig ?? undefined,
googleAuthConfig: config.googleAuthConfig
? {
...record.googleAuthConfig,
...config.googleAuthConfig,
domainToAdminEmailList: convertDomainMappingsToList(
record.googleAuthConfig.domainToAdminEmail,
config.googleAuthConfig.domainToAdminEmail,
),
}
: undefined,
roleMapping: record.roleMapping
roleMapping: config.roleMapping
? {
...record.roleMapping,
...config.roleMapping,
groupMappingsList: convertGroupMappingsToList(
record.roleMapping.groupMappings,
config.roleMapping.groupMappings,
),
}
: undefined,

View File

@@ -43,11 +43,11 @@ function SSOEnforcementToggle({
data: {
config: {
ssoEnabled: checked,
ssoType: record.ssoType,
googleAuthConfig: record.googleAuthConfig,
oidcConfig: record.oidcConfig,
samlConfig: record.samlConfig,
roleMapping: record.roleMapping,
ssoType: record.config?.ssoType,
googleAuthConfig: record.config?.googleAuthConfig,
oidcConfig: record.config?.oidcConfig,
samlConfig: record.config?.samlConfig,
roleMapping: record.config?.roleMapping,
},
},
},

View File

@@ -55,7 +55,10 @@ describe('SSOEnforcementToggle', () => {
render(
<SSOEnforcementToggle
isDefaultChecked={false}
record={{ ...mockGoogleAuthDomain, ssoEnabled: false }}
record={{
...mockGoogleAuthDomain,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
}}
/>,
);

View File

@@ -13,11 +13,13 @@ export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-1',
name: 'signoz.io',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-1',
@@ -28,12 +30,14 @@ export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-2',
name: 'example.com',
ssoEnabled: false,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
config: {
ssoEnabled: false,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-2',
@@ -44,12 +48,14 @@ export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-3',
name: 'corp.io',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-3',
@@ -60,20 +66,22 @@ export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-4',
name: 'enterprise.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
samlCert: 'MOCK_CERTIFICATE',
},
roleMapping: {
defaultRole: 'EDITOR',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
samlCert: 'MOCK_CERTIFICATE',
},
roleMapping: {
defaultRole: 'EDITOR',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
},
},
},
authNProviderInfo: {
@@ -86,16 +94,18 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-5',
name: 'direct-role.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: true,
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-5',
@@ -106,20 +116,22 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-6',
name: 'oidc-claims.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
clientId: 'claims-client-id',
clientSecret: 'claims-client-secret',
insecureSkipEmailVerified: true,
getUserInfo: true,
claimMapping: {
email: 'user_email',
name: 'display_name',
groups: 'user_groups',
role: 'user_role',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
clientId: 'claims-client-id',
clientSecret: 'claims-client-secret',
insecureSkipEmailVerified: true,
getUserInfo: true,
claimMapping: {
email: 'user_email',
name: 'display_name',
groups: 'user_groups',
role: 'user_role',
},
},
},
authNProviderInfo: {
@@ -131,17 +143,19 @@ export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-7',
name: 'saml-attrs.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
samlCert: 'MOCK_CERTIFICATE_ATTRS',
insecureSkipAuthNRequestsSigned: true,
attributeMapping: {
name: 'user_display_name',
groups: 'member_of',
role: 'signoz_role',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
samlCert: 'MOCK_CERTIFICATE_ATTRS',
insecureSkipAuthNRequestsSigned: true,
attributeMapping: {
name: 'user_display_name',
groups: 'member_of',
role: 'signoz_role',
},
},
},
authNProviderInfo: {
@@ -154,19 +168,21 @@ export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-8',
name: 'google-groups.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',
insecureSkipEmailVerified: false,
fetchGroups: true,
serviceAccountJson: '{"type": "service_account"}',
domainToAdminEmail: {
'google-groups.com': 'admin@google-groups.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',
insecureSkipEmailVerified: false,
fetchGroups: true,
serviceAccountJson: '{"type": "service_account"}',
domainToAdminEmail: {
'google-groups.com': 'admin@google-groups.com',
},
fetchTransitiveGroupMembership: true,
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
},
fetchTransitiveGroupMembership: true,
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-8',
@@ -191,15 +207,19 @@ export const mockSingleDomainResponse = {
data: [mockGoogleAuthDomain],
};
// Mock success responses
// Mock success responses. CreateAuthDomain returns just an Identifiable
// (the new domain ID); clients re-Read to get the full domain.
export const mockCreateSuccessResponse = {
status: 'success',
data: mockGoogleAuthDomain,
data: { id: mockGoogleAuthDomain.id },
};
export const mockUpdateSuccessResponse = {
status: 'success',
data: { ...mockGoogleAuthDomain, ssoEnabled: false },
data: {
...mockGoogleAuthDomain,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
},
};
export const mockDeleteSuccessResponse = {

View File

@@ -158,7 +158,7 @@ function AuthDomain(): JSX.Element {
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.ssoType || '')}
Configure {SSOType.get(record.config?.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"

View File

@@ -34,9 +34,9 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
Description: "This endpoint creates an auth domain",
Request: new(authtypes.PostableAuthDomain),
RequestContentType: "application/json",
Response: new(authtypes.GettableAuthDomain),
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
@@ -66,7 +66,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
Tags: []string{"authdomains"},
Summary: "Update auth domain",
Description: "This endpoint updates an auth domain",
Request: new(authtypes.UpdateableAuthDomain),
Request: new(authtypes.UpdatableAuthDomain),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",

View File

@@ -142,7 +142,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
body := new(authtypes.UpdateableAuthDomain)
body := new(authtypes.UpdatableAuthDomain)
if err := binding.JSON.BindBody(r.Body, body); err != nil {
render.Error(rw, err)
return

View File

@@ -44,6 +44,8 @@ import (
"gopkg.in/yaml.v2"
)
const signozDiscriminatorKey string = "x-signoz-discriminator"
type OpenAPI struct {
apiserver apiserver.APIServer
reflector *openapi3.Reflector
@@ -142,6 +144,8 @@ func (openapi *OpenAPI) CreateAndWrite(path string) error {
return err
}
attachDiscriminators(openapi.reflector.Spec)
// The library's MarshalYAML does a JSON round-trip that converts all numbers
// to float64, causing large integers (e.g. epoch millisecond timestamps) to
// render in scientific notation (1.6409952e+12).
@@ -199,3 +203,59 @@ func convertJSONNumbers(v interface{}) {
}
}
}
// attachDiscriminators promotes x-signoz-discriminator extensions
// into openapi3 Discriminator fields. Malformed markers are dropped.
func attachDiscriminators(spec *openapi3.Spec) {
if spec.Components == nil || spec.Components.Schemas == nil {
return
}
for name, entry := range spec.Components.Schemas.MapOfSchemaOrRefValues {
if entry.Schema == nil {
continue
}
raw, ok := entry.Schema.MapOfAnything[signozDiscriminatorKey]
if !ok {
continue
}
marker, ok := raw.(map[string]any)
if !ok {
continue
}
propertyName, ok := marker["propertyName"].(string)
if !ok || propertyName == "" {
continue
}
disc := openapi3.Discriminator{PropertyName: propertyName}
if rawMapping, ok := marker["mapping"]; ok {
if mapping, ok := rawMapping.(map[string]string); ok {
disc.Mapping = mapping
} else if mapping, ok := rawMapping.(map[string]any); ok {
converted := make(map[string]string, len(mapping))
for k, v := range mapping {
if s, ok := v.(string); ok {
converted[k] = s
}
}
disc.Mapping = converted
}
}
entry.Schema.Discriminator = &disc
delete(entry.Schema.MapOfAnything, signozDiscriminatorKey)
// The parent's reflected `properties` / `required` duplicate
// what the oneOf variants already declare, and orval intersects
// the two — turning a clean discriminated union DTO into a
// noisy union of intersections. Drop them here.
entry.Schema.Properties = nil
entry.Schema.Required = nil
spec.Components.Schemas.MapOfSchemaOrRefValues[name] = entry
}
}

View File

@@ -30,7 +30,7 @@ var (
type GettableAuthDomain struct {
StorableAuthDomain
AuthDomainConfig
Config AuthDomainConfig `json:"config"`
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
@@ -43,7 +43,7 @@ type PostableAuthDomain struct {
Name string `json:"name"`
}
type UpdateableAuthDomain struct {
type UpdatableAuthDomain struct {
Config AuthDomainConfig `json:"config"`
}
@@ -121,7 +121,7 @@ func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain)
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
return &GettableAuthDomain{
StorableAuthDomain: *authDomain.StorableAuthDomain(),
AuthDomainConfig: *authDomain.AuthDomainConfig(),
Config: *authDomain.AuthDomainConfig(),
AuthNProviderInfo: authNProviderInfo,
}
}

View File

@@ -250,17 +250,20 @@ type EvaluationEnvelope struct {
// evaluationRolling is the OpenAPI schema for an EvaluationEnvelope with kind=rolling.
type evaluationRolling struct {
Kind EvaluationKind `json:"kind" description:"The kind of evaluation."`
Spec RollingWindow `json:"spec" description:"The rolling window evaluation specification."`
Kind EvaluationKind `json:"kind" description:"The kind of evaluation." required:"true"`
Spec RollingWindow `json:"spec" description:"The rolling window evaluation specification." required:"true"`
}
// evaluationCumulative is the OpenAPI schema for an EvaluationEnvelope with kind=cumulative.
type evaluationCumulative struct {
Kind EvaluationKind `json:"kind" description:"The kind of evaluation."`
Spec CumulativeWindow `json:"spec" description:"The cumulative window evaluation specification."`
Kind EvaluationKind `json:"kind" description:"The kind of evaluation." required:"true"`
Spec CumulativeWindow `json:"spec" description:"The cumulative window evaluation specification." required:"true"`
}
var _ jsonschema.OneOfExposer = EvaluationEnvelope{}
var (
_ jsonschema.OneOfExposer = EvaluationEnvelope{}
_ jsonschema.Preparer = EvaluationEnvelope{}
)
// JSONSchemaOneOf returns the oneOf variants for the EvaluationEnvelope discriminated union.
// Each variant represents a different evaluation kind with its corresponding spec schema.
@@ -271,6 +274,22 @@ func (EvaluationEnvelope) JSONSchemaOneOf() []any {
}
}
func (EvaluationEnvelope) PrepareJSONSchema(schema *jsonschema.Schema) error {
if schema.ExtraProperties == nil {
schema.ExtraProperties = map[string]any{}
}
schema.ExtraProperties["x-signoz-discriminator"] = map[string]any{
"propertyName": "kind",
"mapping": map[string]string{
"rolling": "#/components/schemas/RuletypesEvaluationRolling",
"cumulative": "#/components/schemas/RuletypesEvaluationCumulative",
},
}
return nil
}
func (e *EvaluationEnvelope) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {

View File

@@ -36,11 +36,14 @@ type RuleThresholdData struct {
// thresholdBasic is the OpenAPI schema for a RuleThresholdData with kind=basic.
type thresholdBasic struct {
Kind ThresholdKind `json:"kind" description:"The kind of threshold."`
Spec BasicRuleThresholds `json:"spec" description:"The basic threshold specification (array of thresholds)."`
Kind ThresholdKind `json:"kind" description:"The kind of threshold." required:"true"`
Spec BasicRuleThresholds `json:"spec" description:"The basic threshold specification (array of thresholds)." required:"true"`
}
var _ jsonschema.OneOfExposer = RuleThresholdData{}
var (
_ jsonschema.OneOfExposer = RuleThresholdData{}
_ jsonschema.Preparer = RuleThresholdData{}
)
// JSONSchemaOneOf returns the oneOf variants for the RuleThresholdData discriminated union.
// Each variant represents a different threshold kind with its corresponding spec schema.
@@ -50,6 +53,24 @@ func (RuleThresholdData) JSONSchemaOneOf() []any {
}
}
// PrepareJSONSchema marks the schema with x-signoz-discriminator;
// signoz.attachDiscriminators promotes it to a real OpenAPI 3
// discriminator after reflection.
func (RuleThresholdData) PrepareJSONSchema(schema *jsonschema.Schema) error {
if schema.ExtraProperties == nil {
schema.ExtraProperties = map[string]any{}
}
schema.ExtraProperties["x-signoz-discriminator"] = map[string]any{
"propertyName": "kind",
"mapping": map[string]string{
"basic": "#/components/schemas/RuletypesThresholdBasic",
},
}
return nil
}
func (r *RuleThresholdData) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {

View File

@@ -86,7 +86,7 @@ def test_create_and_get_domain(
"domain-google.integration.test",
"domain-saml.integration.test",
]
assert domain["ssoType"] in ["google_auth", "saml"]
assert domain["config"]["ssoType"] in ["google_auth", "saml"]
def test_create_invalid(