Compare commits

...

13 Commits

Author SHA1 Message Date
SagarRajput-7
f8f4e6872d feat: updated test cases 2026-02-20 03:01:00 +05:30
SagarRajput-7
d45715a501 Merge branch 'main' into SIG-1722-endpoint-update 2026-02-20 02:29:35 +05:30
SagarRajput-7
511c00f15b feat: added test cases all the upgraded apis 2026-02-20 02:29:02 +05:30
SagarRajput-7
cafe4786c1 feat: upgraded the update profile api 2026-02-20 02:14:05 +05:30
Pandey
80c46b3414 chore: bump clickhouse to 25.10.5 (#10369)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
2026-02-20 02:08:27 +05:30
SagarRajput-7
a11fbc3c34 feat: upgraded api in DataSourceInfo 2026-02-20 01:23:00 +05:30
SagarRajput-7
13a5ace051 Merge branch 'main' into SIG-1722-endpoint-update 2026-02-19 23:07:13 +05:30
SagarRajput-7
56f3ad1570 feat: upgraded custom domain apis and used new open api spec 2026-02-19 23:06:13 +05:30
Srikanth Chekuri
2b929421a1 chore: add notes about struct tags for better openapi integration (#10365) 2026-02-19 22:32:44 +05:30
Nityananda Gohain
2792e20aa2 fix: instrumentation changes to capture query duration properly (#10362)
* fix: instrumentation changes to capture query duration properly

* chore: move changes to rows.go
2026-02-19 16:02:16 +00:00
Karan Balani
473be1b174 feat: add zeus handler with profile and host apis (#10336)
### 📄 Summary
- Expose Zeus PutProfile, PutHost and GetHost APIs as first-class OpenAPI-spec endpoints, replacing the previous proxy-based approach
- Introduce typed request structs (PostableProfile, PostableHost) instead of raw []byte for type safety and OpenAPI documentation
- Wire Zeus handler through the standard dependency chain: handler interface, handler implementation, Handlers struct, signozapiserver provider

#### Changes
- PUT /api/v2/zeus/profiles - saves deployment profile to Zeus
- PUT /api/v2/zeus/hosts - saves deployment host to Zeus
- GET /api/v2/zeus/hosts - gets the deployment host from Zeus
- All the above new APIs need Admin access

Also:
- httpzeus provider — marshaling now happens in the provider; upstream error messages are passed through instead of being swallowed; fixes wrong upstream path (/hosts → /host); adds 409 Conflict mapping; replaces errors.Newf with errors.New

#### Issues closed by this PR
Closes https://github.com/SigNoz/platform-pod/issues/1722
2026-02-19 15:40:37 +00:00
Ashwin Bhatkal
6d0c13f9a7 fix: dynamic variables options load first time (#10361)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-19 20:25:19 +05:30
SagarRajput-7
5cc562ba35 feat: added roles page and listing view (#10329)
* feat: added roles page and listing view

* feat: refactored to use usetimezone hook and scss refactor

* feat: added page in url params and refactors

* feat: used semantic tokens for scss change
2026-02-19 13:45:42 +00:00
51 changed files with 2555 additions and 308 deletions

View File

@@ -53,9 +53,9 @@ jobs:
- sqlite
clickhouse-version:
- 25.5.6
- 25.10.1
- 25.10.5
schema-migrator-version:
- v0.129.7
- v0.142.0
postgres-version:
- 15
if: |

View File

@@ -1920,6 +1920,63 @@ components:
format: date-time
type: string
type: object
ZeustypesGettableHost:
properties:
hosts:
items:
$ref: '#/components/schemas/ZeustypesHost'
nullable: true
type: array
name:
type: string
state:
type: string
tier:
type: string
type: object
ZeustypesHost:
properties:
is_default:
type: boolean
name:
type: string
url:
type: string
type: object
ZeustypesPostableHost:
properties:
name:
type: string
required:
- name
type: object
ZeustypesPostableProfile:
properties:
existing_observability_tool:
type: string
has_existing_observability_tool:
type: boolean
logs_scale_per_day_in_gb:
format: int64
type: integer
number_of_hosts:
format: int64
type: integer
number_of_services:
format: int64
type: integer
reasons_for_interest_in_signoz:
items:
type: string
nullable: true
type: array
timeline_for_migrating_to_signoz:
type: string
uses_otel:
type: boolean
where_did_you_discover_signoz:
type: string
type: object
securitySchemes:
api_key:
description: API Keys
@@ -5538,6 +5595,174 @@ paths:
summary: Rotate session
tags:
- sessions
/api/v2/zeus/hosts:
get:
deprecated: false
description: This endpoint gets the host info from zeus.
operationId: GetHosts
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/ZeustypesGettableHost'
status:
type: string
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"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 host info from Zeus.
tags:
- zeus
put:
deprecated: false
description: This endpoint saves the host of a deployment to zeus.
operationId: PutHost
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ZeustypesPostableHost'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Put host in Zeus for a deployment.
tags:
- zeus
/api/v2/zeus/profiles:
put:
deprecated: false
description: This endpoint saves the profile of a deployment to zeus.
operationId: PutProfile
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ZeustypesPostableProfile'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Put profile in Zeus for a deployment.
tags:
- zeus
/api/v5/query_range:
post:
deprecated: false

View File

@@ -155,6 +155,7 @@ The `handler.New` function ties the HTTP handler to OpenAPI metadata via `OpenAP
- **Request / RequestContentType**:
- `Request` is a Go type that describes the request body or form.
- `RequestContentType` is usually `"application/json"` or `"application/x-www-form-urlencoded"` (for callbacks like SAML).
- **RequestExamples**: An array of `handler.OpenAPIExample` that provide concrete request payloads in the generated spec. See [Adding request examples](#adding-request-examples) below.
- **Response / ResponseContentType**:
- `Response` is the Go type for the successful response payload.
- `ResponseContentType` is usually `"application/json"`; use `""` for responses without a body.
@@ -172,8 +173,169 @@ See existing examples in:
- `addUserRoutes` (for typical JSON request/response)
- `addSessionRoutes` (for form-encoded and redirect flows)
## OpenAPI schema details for request/response types
The OpenAPI spec is generated from the Go types you pass as `Request` and `Response` in `OpenAPIDef`. The following struct tags and interfaces control how those types appear in the generated schema.
### Adding request examples
Use the `RequestExamples` field in `OpenAPIDef` to provide concrete request payloads. Each example is a `handler.OpenAPIExample`:
```go
type OpenAPIExample struct {
Name string // unique key for the example (e.g. "traces_time_series")
Summary string // short description shown in docs (e.g. "Time series: count spans grouped by service")
Description string // optional longer description
Value any // the example payload, typically map[string]any
}
```
For reference, see `pkg/querier/openapi.go` which defines some examples for the `/api/v5/query_range` endpoint:
```go
var QueryRangeV5OpenAPIDef = handler.OpenAPIDef{
ID: "QueryRangeV5",
Tags: []string{"query"},
Summary: "Query range",
Description: "Execute a composite query over a time range.",
Request: new(qbtypes.QueryRangeRequest),
RequestExamples: queryRangeV5Examples, // []handler.OpenAPIExample
// ...
}
var queryRangeV5Examples = []handler.OpenAPIExample{
{
Name: "traces_time_series",
Summary: "Time series: count spans grouped by service",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
// ...
},
},
},
},
},
},
// ... more examples
}
```
### `required` tag
Use `required:"true"` on struct fields where the property is expected to be **present** in the JSON payload. This is different from the zero value, a field can have its zero value (e.g. `0`, `""`, `false`) and still be required. The `required` tag means the key itself must exist in the JSON object.
```go
type ListItem struct {
...
}
type ListResponse struct {
List []ListItem `json:"list" required:"true" nullable:"true"`
Total uint64 `json:"total" required:"true"`
}
```
In this example, a response like `{"list": null, "total": 0}` is valid. Both keys are present (satisfying `required`), `total` has its zero value, and `list` is null (allowed by `nullable`). But `{"total": 0}` would violate the schema because the `list` key is missing.
### `nullable` tag
Use `nullable:"true"` on struct fields that can be `null` in the JSON payload. This is especially important for **slice and map fields** because in Go, the zero value for these types is `nil`, which serializes to `null` in JSON (not `[]` or `{}`).
Be explicit about the distinction:
- **Nullable list** (`nullable:"true"`): the field can be `null`. Use this when the Go code may return `nil` for the slice.
- **Non-nullable list** (no `nullable` tag): the field is always an array, never `null`. Ensure the Go code initializes it to an empty slice (e.g. `make([]T, 0)`) before serializing.
```go
// Non-nullable: Go code must ensure this is always an initialized slice.
type NonNullableExample struct {
Items []Item `json:"items" required:"true"`
}
```
When defining your types, ask yourself: "Can this field be `null` in the JSON response, or is it always an array/object?" If the Go code ever returns a `nil` slice or map, mark it `nullable:"true"`.
### `Enum()` method
For types that have a fixed set of acceptable values, implement the `Enum() []any` method. This generates an `enum` constraint in the JSON schema so the OpenAPI spec accurately restricts the values.
```go
type Signal struct {
valuer.String
}
var (
SignalTraces = Signal{valuer.NewString("traces")}
SignalLogs = Signal{valuer.NewString("logs")}
SignalMetrics = Signal{valuer.NewString("metrics")}
)
func (Signal) Enum() []any {
return []any{
SignalTraces,
SignalLogs,
SignalMetrics,
}
}
```
This produces the following in the generated OpenAPI spec:
```yaml
Signal:
enum:
- traces
- logs
- metrics
type: string
```
Every type with a known set of values **must** implement `Enum()`. Without it, the JSON schema will only show the base type (e.g. `string`) with no value constraints.
### `JSONSchema()` method (custom schema)
For types that need a completely custom JSON schema (for example, a field that accepts either a string or a number), implement the `jsonschema.Exposer` interface:
```go
var _ jsonschema.Exposer = Step{}
func (Step) JSONSchema() (jsonschema.Schema, error) {
s := jsonschema.Schema{}
s.WithDescription("Step interval. Accepts a duration string or seconds.")
strSchema := jsonschema.Schema{}
strSchema.WithType(jsonschema.String.Type())
strSchema.WithExamples("60s", "5m", "1h")
numSchema := jsonschema.Schema{}
numSchema.WithType(jsonschema.Number.Type())
numSchema.WithExamples(60, 300, 3600)
s.OneOf = []jsonschema.SchemaOrBool{
strSchema.ToSchemaOrBool(),
numSchema.ToSchemaOrBool(),
}
return s, nil
}
```
## What should I remember?
- **Keep handlers thin**: focus on HTTP concerns and delegate logic to modules/services.
- **Always register routes through `signozapiserver`** using `handler.New` and a complete `OpenAPIDef`.
- **Choose accurate request/response types** from the `types` packages so OpenAPI schemas are correct.
- **Add `required:"true"`** on fields where the key must be present in the JSON (this is about key presence, not about the zero value).
- **Add `nullable:"true"`** on fields that can be `null`. Pay special attention to slices and maps -- in Go these default to `nil` which serializes to `null`. If the field should always be an array, initialize it and do not mark it nullable.
- **Implement `Enum()`** on every type that has a fixed set of acceptable values so the JSON schema generates proper `enum` constraints.
- **Add request examples** via `RequestExamples` in `OpenAPIDef` for any non-trivial endpoint. See `pkg/querier/openapi.go` for reference.

View File

@@ -3,6 +3,7 @@ package httpzeus
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
@@ -10,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/tidwall/gjson"
)
@@ -119,8 +121,13 @@ func (provider *Provider) PutMeters(ctx context.Context, key string, data []byte
return err
}
func (provider *Provider) PutProfile(ctx context.Context, key string, body []byte) error {
_, err := provider.do(
func (provider *Provider) PutProfile(ctx context.Context, key string, profile *zeustypes.PostableProfile) error {
body, err := json.Marshal(profile)
if err != nil {
return err
}
_, err = provider.do(
ctx,
provider.config.URL.JoinPath("/v2/profiles/me"),
http.MethodPut,
@@ -131,10 +138,15 @@ func (provider *Provider) PutProfile(ctx context.Context, key string, body []byt
return err
}
func (provider *Provider) PutHost(ctx context.Context, key string, body []byte) error {
_, err := provider.do(
func (provider *Provider) PutHost(ctx context.Context, key string, host *zeustypes.PostableHost) error {
body, err := json.Marshal(host)
if err != nil {
return err
}
_, err = provider.do(
ctx,
provider.config.URL.JoinPath("/v2/deployments/me/hosts"),
provider.config.URL.JoinPath("/v2/deployments/me/host"),
http.MethodPut,
key,
body,
@@ -169,21 +181,28 @@ func (provider *Provider) do(ctx context.Context, url *url.URL, method string, k
return body, nil
}
return nil, provider.errFromStatusCode(response.StatusCode)
errorMessage := gjson.GetBytes(body, "error").String()
if errorMessage == "" {
errorMessage = "an unknown error occurred"
}
return nil, provider.errFromStatusCode(response.StatusCode, errorMessage)
}
// This can be taken down to the client package
func (provider *Provider) errFromStatusCode(statusCode int) error {
func (provider *Provider) errFromStatusCode(statusCode int, errorMessage string) error {
switch statusCode {
case http.StatusBadRequest:
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "bad request")
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, errorMessage)
case http.StatusUnauthorized:
return errors.Newf(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "unauthenticated")
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, errorMessage)
case http.StatusForbidden:
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "forbidden")
return errors.New(errors.TypeForbidden, errors.CodeForbidden, errorMessage)
case http.StatusNotFound:
return errors.Newf(errors.TypeNotFound, errors.CodeNotFound, "not found")
return errors.New(errors.TypeNotFound, errors.CodeNotFound, errorMessage)
case http.StatusConflict:
return errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, errorMessage)
}
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "internal")
return errors.New(errors.TypeInternal, errors.CodeInternal, errorMessage)
}

View File

@@ -12,5 +12,6 @@
"pipeline": "Pipeline",
"pipelines": "Pipelines",
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics"
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles"
}

View File

@@ -12,5 +12,6 @@
"pipeline": "Pipeline",
"pipelines": "Pipelines",
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics"
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles"
}

View File

@@ -73,5 +73,6 @@
"API_MONITORING": "SigNoz | External APIs",
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter"
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles"
}

View File

@@ -1,7 +0,0 @@
import { GatewayApiV2Instance as axios } from 'api';
import { AxiosResponse } from 'axios';
import { DeploymentsDataProps } from 'types/api/customDomain/types';
export const getDeploymentsData = (): Promise<
AxiosResponse<DeploymentsDataProps>
> => axios.get(`/deployments/me`);

View File

@@ -1,16 +0,0 @@
import { GatewayApiV2Instance as axios } from 'api';
import { AxiosError } from 'axios';
import { SuccessResponse } from 'types/api';
import {
PayloadProps,
UpdateCustomDomainProps,
} from 'types/api/customDomain/types';
const updateSubDomainAPI = async (
props: UpdateCustomDomainProps,
): Promise<SuccessResponse<PayloadProps> | AxiosError> =>
axios.put(`/deployments/me/host`, {
...props.data,
});
export default updateSubDomainAPI;

View File

@@ -2414,6 +2414,91 @@ export interface TypesUserDTO {
updatedAt?: Date;
}
export interface ZeustypesGettableHostDTO {
/**
* @type array
* @nullable true
*/
hosts?: ZeustypesHostDTO[] | null;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
state?: string;
/**
* @type string
*/
tier?: string;
}
export interface ZeustypesHostDTO {
/**
* @type boolean
*/
is_default?: boolean;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
url?: string;
}
export interface ZeustypesPostableHostDTO {
/**
* @type string
*/
name: string;
}
export interface ZeustypesPostableProfileDTO {
/**
* @type string
*/
existing_observability_tool?: string;
/**
* @type boolean
*/
has_existing_observability_tool?: boolean;
/**
* @type integer
* @format int64
*/
logs_scale_per_day_in_gb?: number;
/**
* @type integer
* @format int64
*/
number_of_hosts?: number;
/**
* @type integer
* @format int64
*/
number_of_services?: number;
/**
* @type array
* @nullable true
*/
reasons_for_interest_in_signoz?: string[] | null;
/**
* @type string
*/
timeline_for_migrating_to_signoz?: string;
/**
* @type boolean
*/
uses_otel?: boolean;
/**
* @type string
*/
where_did_you_discover_signoz?: string;
}
export type ChangePasswordPathParameters = {
id: string;
};
@@ -3129,6 +3214,14 @@ export type RotateSession200 = {
status?: string;
};
export type GetHosts200 = {
data?: ZeustypesGettableHostDTO;
/**
* @type string
*/
status?: string;
};
export type QueryRangeV5200 = {
data?: Querybuildertypesv5QueryRangeResponseDTO;
/**

View File

@@ -0,0 +1,269 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { useMutation, useQuery } from 'react-query';
import { GeneratedAPIInstance } from '../../../index';
import type {
GetHosts200,
RenderErrorResponseDTO,
ZeustypesPostableHostDTO,
ZeustypesPostableProfileDTO,
} from '../sigNoz.schemas';
type AwaitedInput<T> = PromiseLike<T> | T;
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
/**
* This endpoint gets the host info from zeus.
* @summary Get host info from Zeus.
*/
export const getHosts = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetHosts200>({
url: `/api/v2/zeus/hosts`,
method: 'GET',
signal,
});
};
export const getGetHostsQueryKey = () => {
return ['getHosts'] as const;
};
export const getGetHostsQueryOptions = <
TData = Awaited<ReturnType<typeof getHosts>>,
TError = RenderErrorResponseDTO
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getHosts>>, TError, TData>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetHostsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getHosts>>> = ({
signal,
}) => getHosts(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getHosts>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetHostsQueryResult = NonNullable<
Awaited<ReturnType<typeof getHosts>>
>;
export type GetHostsQueryError = RenderErrorResponseDTO;
/**
* @summary Get host info from Zeus.
*/
export function useGetHosts<
TData = Awaited<ReturnType<typeof getHosts>>,
TError = RenderErrorResponseDTO
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getHosts>>, TError, TData>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetHostsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get host info from Zeus.
*/
export const invalidateGetHosts = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetHostsQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint saves the host of a deployment to zeus.
* @summary Put host in Zeus for a deployment.
*/
export const putHost = (zeustypesPostableHostDTO: ZeustypesPostableHostDTO) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/zeus/hosts`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: zeustypesPostableHostDTO,
});
};
export const getPutHostMutationOptions = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof putHost>>,
TError,
{ data: ZeustypesPostableHostDTO },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof putHost>>,
TError,
{ data: ZeustypesPostableHostDTO },
TContext
> => {
const mutationKey = ['putHost'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof putHost>>,
{ data: ZeustypesPostableHostDTO }
> = (props) => {
const { data } = props ?? {};
return putHost(data);
};
return { mutationFn, ...mutationOptions };
};
export type PutHostMutationResult = NonNullable<
Awaited<ReturnType<typeof putHost>>
>;
export type PutHostMutationBody = ZeustypesPostableHostDTO;
export type PutHostMutationError = RenderErrorResponseDTO;
/**
* @summary Put host in Zeus for a deployment.
*/
export const usePutHost = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof putHost>>,
TError,
{ data: ZeustypesPostableHostDTO },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof putHost>>,
TError,
{ data: ZeustypesPostableHostDTO },
TContext
> => {
const mutationOptions = getPutHostMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint saves the profile of a deployment to zeus.
* @summary Put profile in Zeus for a deployment.
*/
export const putProfile = (
zeustypesPostableProfileDTO: ZeustypesPostableProfileDTO,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/zeus/profiles`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: zeustypesPostableProfileDTO,
});
};
export const getPutProfileMutationOptions = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof putProfile>>,
TError,
{ data: ZeustypesPostableProfileDTO },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof putProfile>>,
TError,
{ data: ZeustypesPostableProfileDTO },
TContext
> => {
const mutationKey = ['putProfile'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof putProfile>>,
{ data: ZeustypesPostableProfileDTO }
> = (props) => {
const { data } = props ?? {};
return putProfile(data);
};
return { mutationFn, ...mutationOptions };
};
export type PutProfileMutationResult = NonNullable<
Awaited<ReturnType<typeof putProfile>>
>;
export type PutProfileMutationBody = ZeustypesPostableProfileDTO;
export type PutProfileMutationError = RenderErrorResponseDTO;
/**
* @summary Put profile in Zeus for a deployment.
*/
export const usePutProfile = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof putProfile>>,
TError,
{ data: ZeustypesPostableProfileDTO },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof putProfile>>,
TError,
{ data: ZeustypesPostableProfileDTO },
TContext
> => {
const mutationOptions = getPutProfileMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -1,20 +0,0 @@
import { GatewayApiV2Instance } from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { UpdateProfileProps } from 'types/api/onboarding/types';
const updateProfile = async (
props: UpdateProfileProps,
): Promise<SuccessResponse<UpdateProfileProps> | ErrorResponse> => {
const response = await GatewayApiV2Instance.put('/profiles/me', {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default updateProfile;

View File

@@ -55,6 +55,7 @@ const ROUTES = {
LOGS_INDEX_FIELDS: '/logs-explorer/index-fields',
TRACE_EXPLORER: '/trace-explorer',
BILLING: '/settings/billing',
ROLES_SETTINGS: '/settings/roles',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
TRACES_SAVE_VIEWS: '/traces/saved-views',

View File

@@ -1,7 +1,6 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { useEffect, useState } from 'react';
import { useMutation } from 'react-query';
import { useEffect, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Color } from '@signozhq/design-tokens';
import {
@@ -15,14 +14,16 @@ import {
Tag,
Typography,
} from 'antd';
import updateSubDomainAPI from 'api/customDomain/updateSubDomain';
import {
RenderErrorResponseDTO,
ZeustypesHostDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useGetHosts, usePutHost } from 'api/generated/services/zeus';
import { AxiosError } from 'axios';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
import { useGetDeploymentsData } from 'hooks/CustomDomain/useGetDeploymentsData';
import { useNotifications } from 'hooks/useNotifications';
import { InfoIcon, Link2, Pencil } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { HostsProps } from 'types/api/customDomain/types';
import './CustomDomainSettings.styles.scss';
@@ -35,7 +36,7 @@ export default function CustomDomainSettings(): JSX.Element {
const { notifications } = useNotifications();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isPollingEnabled, setIsPollingEnabled] = useState(false);
const [hosts, setHosts] = useState<HostsProps[] | null>(null);
const [hosts, setHosts] = useState<ZeustypesHostDTO[] | null>(null);
const [updateDomainError, setUpdateDomainError] = useState<AxiosError | null>(
null,
@@ -57,36 +58,37 @@ export default function CustomDomainSettings(): JSX.Element {
};
const {
data: deploymentsData,
isLoading: isLoadingDeploymentsData,
isFetching: isFetchingDeploymentsData,
refetch: refetchDeploymentsData,
} = useGetDeploymentsData(true);
data: hostsData,
isLoading: isLoadingHosts,
isFetching: isFetchingHosts,
refetch: refetchHosts,
} = useGetHosts();
const {
mutate: updateSubDomain,
isLoading: isLoadingUpdateCustomDomain,
} = useMutation(updateSubDomainAPI, {
onSuccess: () => {
setIsPollingEnabled(true);
refetchDeploymentsData();
setIsEditModalOpen(false);
},
onError: (error: AxiosError) => {
setUpdateDomainError(error);
setIsPollingEnabled(false);
},
});
} = usePutHost<AxiosError<RenderErrorResponseDTO>>();
const stripProtocol = (url: string): string => {
return url?.split('://')[1] ?? url;
};
const dnsSuffix = useMemo(() => {
const defaultHost = hosts?.find((h) => h.is_default);
return defaultHost?.url && defaultHost?.name
? defaultHost.url.split(`${defaultHost.name}.`)[1] || ''
: '';
}, [hosts]);
useEffect(() => {
if (isFetchingDeploymentsData) {
if (isFetchingHosts) {
return;
}
if (deploymentsData?.data?.status === 'success') {
setHosts(deploymentsData.data.data.hosts);
if (hostsData?.data?.status === 'success') {
setHosts(hostsData?.data?.data?.hosts ?? null);
const activeCustomDomain = deploymentsData.data.data.hosts.find(
const activeCustomDomain = hostsData?.data?.data?.hosts?.find(
(host) => !host.is_default,
);
@@ -97,32 +99,36 @@ export default function CustomDomainSettings(): JSX.Element {
}
}
if (deploymentsData?.data?.data?.state !== 'HEALTHY' && isPollingEnabled) {
if (hostsData?.data?.data?.state !== 'HEALTHY' && isPollingEnabled) {
setTimeout(() => {
refetchDeploymentsData();
refetchHosts();
}, 3000);
}
if (deploymentsData?.data?.data.state === 'HEALTHY') {
if (hostsData?.data?.data?.state === 'HEALTHY') {
setIsPollingEnabled(false);
}
}, [
deploymentsData,
refetchDeploymentsData,
isPollingEnabled,
isFetchingDeploymentsData,
]);
}, [hostsData, refetchHosts, isPollingEnabled, isFetchingHosts]);
const onUpdateCustomDomainSettings = (): void => {
editForm
.validateFields()
.then((values) => {
if (values.subdomain) {
updateSubDomain({
data: {
name: values.subdomain,
updateSubDomain(
{ data: { name: values.subdomain } },
{
onSuccess: () => {
setIsPollingEnabled(true);
refetchHosts();
setIsEditModalOpen(false);
},
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
setUpdateDomainError(error as AxiosError);
setIsPollingEnabled(false);
},
},
});
);
setCustomDomainDetails({
subdomain: values.subdomain,
@@ -134,10 +140,8 @@ export default function CustomDomainSettings(): JSX.Element {
});
};
const onCopyUrlHandler = (host: string): void => {
const url = `${host}.${deploymentsData?.data.data.cluster.region.dns}`;
setCopyUrl(url);
const onCopyUrlHandler = (url: string): void => {
setCopyUrl(stripProtocol(url));
notifications.success({
message: 'Copied to clipboard',
});
@@ -157,7 +161,7 @@ export default function CustomDomainSettings(): JSX.Element {
</div>
<div className="custom-domain-settings-content">
{!isLoadingDeploymentsData && (
{!isLoadingHosts && (
<Card className="custom-domain-settings-card">
<div className="custom-domain-settings-content-header">
Team {org?.[0]?.displayName} Information
@@ -169,10 +173,9 @@ export default function CustomDomainSettings(): JSX.Element {
<div
className="custom-domain-url"
key={host.name}
onClick={(): void => onCopyUrlHandler(host.name)}
onClick={(): void => onCopyUrlHandler(host.url || '')}
>
<Link2 size={12} /> {host.name}.
{deploymentsData?.data.data.cluster.region.dns}
<Link2 size={12} /> {stripProtocol(host.url || '')}
{host.is_default && <Tag color={Color.BG_ROBIN_500}>Default</Tag>}
</div>
))}
@@ -181,11 +184,7 @@ export default function CustomDomainSettings(): JSX.Element {
<div className="custom-domain-url-edit-btn">
<Button
className="periscope-btn"
disabled={
isLoadingDeploymentsData ||
isFetchingDeploymentsData ||
isPollingEnabled
}
disabled={isLoadingHosts || isFetchingHosts || isPollingEnabled}
type="default"
icon={<Pencil size={10} />}
onClick={(): void => setIsEditModalOpen(true)}
@@ -198,7 +197,7 @@ export default function CustomDomainSettings(): JSX.Element {
{isPollingEnabled && (
<Alert
className="custom-domain-update-status"
message={`Updating your URL to ⎯ ${customDomainDetails?.subdomain}.${deploymentsData?.data.data.cluster.region.dns}. This may take a few mins.`}
message={`Updating your URL to ⎯ ${customDomainDetails?.subdomain}.${dnsSuffix}. This may take a few mins.`}
type="info"
icon={<InfoIcon size={12} />}
/>
@@ -206,7 +205,7 @@ export default function CustomDomainSettings(): JSX.Element {
</Card>
)}
{isLoadingDeploymentsData && (
{isLoadingHosts && (
<Card className="custom-domain-settings-card">
<Skeleton
className="custom-domain-settings-skeleton"
@@ -255,7 +254,7 @@ export default function CustomDomainSettings(): JSX.Element {
addonBefore={updateDomainError && <InfoIcon size={12} color="red" />}
placeholder="Enter Domain"
onChange={(): void => setUpdateDomainError(null)}
addonAfter={deploymentsData?.data.data.cluster.region.dns}
addonAfter={dnsSuffix}
autoFocus
/>
</Form.Item>
@@ -267,7 +266,8 @@ export default function CustomDomainSettings(): JSX.Element {
{updateDomainError.status === 409 ? (
<Alert
message={
(updateDomainError?.response?.data as { error?: string })?.error ||
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
?.message ||
'Youve already updated the custom domain once today. To make further changes, please contact our support team for assistance.'
}
type="warning"
@@ -275,7 +275,10 @@ export default function CustomDomainSettings(): JSX.Element {
/>
) : (
<Typography.Text type="danger">
{(updateDomainError.response?.data as { error: string })?.error}
{
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
?.message
}
</Typography.Text>
)}
</div>

View File

@@ -0,0 +1,128 @@
import { GetHosts200 } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CustomDomainSettings from '../CustomDomainSettings';
const ZEUS_HOSTS_ENDPOINT = '*/api/v2/zeus/hosts';
const mockHostsResponse: GetHosts200 = {
status: 'success',
data: {
name: 'accepted-starfish',
state: 'HEALTHY',
tier: 'PREMIUM',
hosts: [
{
name: 'accepted-starfish',
is_default: true,
url: 'https://accepted-starfish.test.cloud',
},
{
name: 'custom-host',
is_default: false,
url: 'https://custom-host.test.cloud',
},
],
},
};
describe('CustomDomainSettings', () => {
afterEach(() => server.resetHandlers());
it('renders host URLs with protocol stripped and marks the default host', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
render(<CustomDomainSettings />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
await screen.findByText(/custom-host\.test\.cloud/i);
expect(screen.getByText('Default')).toBeInTheDocument();
});
it('opens edit modal with DNS suffix derived from the default host', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /customize team[']s url/i }),
);
expect(
screen.getByRole('dialog', { name: /customize your team[']s url/i }),
).toBeInTheDocument();
// DNS suffix is the part of the default host URL after the name prefix
expect(screen.getByText('test.cloud')).toBeInTheDocument();
});
it('submits PUT to /zeus/hosts with the entered subdomain as the payload', async () => {
let capturedBody: Record<string, unknown> = {};
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
rest.put(ZEUS_HOSTS_ENDPOINT, async (req, res, ctx) => {
capturedBody = await req.json<Record<string, unknown>>();
return res(ctx.status(200), ctx.json({}));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /customize team[']s url/i }),
);
const input = screen.getByPlaceholderText(/enter domain/i);
await user.clear(input);
await user.type(input, 'myteam');
await user.click(screen.getByRole('button', { name: /apply changes/i }));
await waitFor(() => {
expect(capturedBody).toEqual({ name: 'myteam' });
});
});
it('shows contact support option when domain update returns 409', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(409),
ctx.json({ error: { message: 'Already updated today' } }),
),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /customize team[']s url/i }),
);
await user.type(screen.getByPlaceholderText(/enter domain/i), 'myteam');
await user.click(screen.getByRole('button', { name: /apply changes/i }));
expect(
await screen.findByRole('button', { name: /contact support/i }),
).toBeInTheDocument();
});
});

View File

@@ -34,6 +34,9 @@ function DashboardVariableSelection(): JSX.Element | null {
const sortedVariablesArray = useDashboardVariablesSelector(
(state) => state.sortedVariablesArray,
);
const dynamicVariableOrder = useDashboardVariablesSelector(
(state) => state.dynamicVariableOrder,
);
const dependencyData = useDashboardVariablesSelector(
(state) => state.dependencyData,
);
@@ -52,10 +55,11 @@ function DashboardVariableSelection(): JSX.Element | null {
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
// Memoize the order key to avoid unnecessary triggers
const dependencyOrderKey = useMemo(
() => dependencyData?.order?.join(',') ?? '',
[dependencyData?.order],
);
const variableOrderKey = useMemo(() => {
const queryVariableOrderKey = dependencyData?.order?.join(',') ?? '';
const dynamicVariableOrderKey = dynamicVariableOrder?.join(',') ?? '';
return `${queryVariableOrderKey}|${dynamicVariableOrderKey}`;
}, [dependencyData?.order, dynamicVariableOrder]);
// Initialize fetch store then start a new fetch cycle.
// Runs on dependency order changes, and time range changes.
@@ -66,7 +70,7 @@ function DashboardVariableSelection(): JSX.Element | null {
initializeVariableFetchStore(allVariableNames);
enqueueFetchOfAllVariables();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dependencyOrderKey, minTime, maxTime]);
}, [variableOrderKey, minTime, maxTime]);
// Performance optimization: For dynamic variables with allSelected=true, we don't store
// individual values in localStorage since we can always derive them from available options.

View File

@@ -0,0 +1,203 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { act, render } from '@testing-library/react';
import {
dashboardVariablesStore,
setDashboardVariablesStore,
updateDashboardVariablesStore,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
IDashboardVariables,
IDashboardVariablesStoreState,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import {
enqueueFetchOfAllVariables,
initializeVariableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DashboardVariableSelection from '../DashboardVariableSelection';
// Mock providers/Dashboard/Dashboard
const mockSetSelectedDashboard = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): Record<string, unknown> => ({
setSelectedDashboard: mockSetSelectedDashboard,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
}),
}));
// Mock hooks/dashboard/useVariablesFromUrl
const mockUpdateUrlVariable = jest.fn();
const mockGetUrlVariables = jest.fn().mockReturnValue({});
jest.mock('hooks/dashboard/useVariablesFromUrl', () => ({
__esModule: true,
default: (): Record<string, unknown> => ({
updateUrlVariable: mockUpdateUrlVariable,
getUrlVariables: mockGetUrlVariables,
}),
}));
// Mock variableFetchStore functions
jest.mock('providers/Dashboard/store/variableFetchStore', () => ({
initializeVariableFetchStore: jest.fn(),
enqueueFetchOfAllVariables: jest.fn(),
enqueueDescendantsOfVariable: jest.fn(),
}));
// Mock initializeDefaultVariables
jest.mock('providers/Dashboard/initializeDefaultVariables', () => ({
initializeDefaultVariables: jest.fn(),
}));
// Mock react-redux useSelector for globalTime
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
}));
// Mock VariableItem to avoid rendering complexity
jest.mock('../VariableItem', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="variable-item" />,
}));
function createVariable(
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable {
return {
id: 'test-id',
name: 'test-var',
description: '',
type: 'QUERY',
sort: 'DISABLED',
showALLOption: false,
multiSelect: false,
order: 0,
...overrides,
};
}
function resetStore(): void {
dashboardVariablesStore.set(() => ({
dashboardId: '',
variables: {},
sortedVariablesArray: [],
dependencyData: null,
variableTypes: {},
dynamicVariableOrder: [],
}));
}
describe('DashboardVariableSelection', () => {
beforeEach(() => {
resetStore();
jest.clearAllMocks();
});
it('should call initializeVariableFetchStore and enqueueFetchOfAllVariables on mount', () => {
const variables: IDashboardVariables = {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
};
setDashboardVariablesStore({ dashboardId: 'dash-1', variables });
render(<DashboardVariableSelection />);
expect(initializeVariableFetchStore).toHaveBeenCalledWith(['env']);
expect(enqueueFetchOfAllVariables).toHaveBeenCalled();
});
it('should re-trigger fetch cycle when dynamicVariableOrder changes', () => {
const variables: IDashboardVariables = {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
};
setDashboardVariablesStore({ dashboardId: 'dash-1', variables });
render(<DashboardVariableSelection />);
// Clear mocks after initial render
(initializeVariableFetchStore as jest.Mock).mockClear();
(enqueueFetchOfAllVariables as jest.Mock).mockClear();
// Add a DYNAMIC variable which changes dynamicVariableOrder
act(() => {
updateDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
dyn1: createVariable({ name: 'dyn1', type: 'DYNAMIC', order: 1 }),
},
});
});
expect(initializeVariableFetchStore).toHaveBeenCalledWith(
expect.arrayContaining(['env', 'dyn1']),
);
expect(enqueueFetchOfAllVariables).toHaveBeenCalled();
});
it('should re-trigger fetch cycle when a dynamic variable is removed', () => {
const variables: IDashboardVariables = {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
dyn1: createVariable({ name: 'dyn1', type: 'DYNAMIC', order: 1 }),
dyn2: createVariable({ name: 'dyn2', type: 'DYNAMIC', order: 2 }),
};
setDashboardVariablesStore({ dashboardId: 'dash-1', variables });
render(<DashboardVariableSelection />);
(initializeVariableFetchStore as jest.Mock).mockClear();
(enqueueFetchOfAllVariables as jest.Mock).mockClear();
// Remove dyn2, changing dynamicVariableOrder from ['dyn1','dyn2'] to ['dyn1']
act(() => {
updateDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
dyn1: createVariable({ name: 'dyn1', type: 'DYNAMIC', order: 1 }),
},
});
});
expect(initializeVariableFetchStore).toHaveBeenCalledWith(['env', 'dyn1']);
expect(enqueueFetchOfAllVariables).toHaveBeenCalled();
});
it('should NOT re-trigger fetch cycle when dynamicVariableOrder stays the same', () => {
const variables: IDashboardVariables = {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
dyn1: createVariable({ name: 'dyn1', type: 'DYNAMIC', order: 1 }),
};
setDashboardVariablesStore({ dashboardId: 'dash-1', variables });
render(<DashboardVariableSelection />);
(initializeVariableFetchStore as jest.Mock).mockClear();
(enqueueFetchOfAllVariables as jest.Mock).mockClear();
// Update a non-dynamic variable's selectedValue — dynamicVariableOrder unchanged
act(() => {
const snapshot = dashboardVariablesStore.getSnapshot();
dashboardVariablesStore.set(
(): IDashboardVariablesStoreState => ({
...snapshot,
variables: {
...snapshot.variables,
env: {
...snapshot.variables.env,
selectedValue: 'production',
},
},
}),
);
});
expect(initializeVariableFetchStore).not.toHaveBeenCalled();
expect(enqueueFetchOfAllVariables).not.toHaveBeenCalled();
});
});

View File

@@ -2,10 +2,10 @@
import { useEffect, useState } from 'react';
import { Button, Skeleton, Tag, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetHosts } from 'api/generated/services/zeus';
import ROUTES from 'constants/routes';
import { useGetDeploymentsData } from 'hooks/CustomDomain/useGetDeploymentsData';
import history from 'lib/history';
import { Globe, Link2 } from 'lucide-react';
import { Link2 } from 'lucide-react';
import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform } from 'types/api/licensesV3/getActive';
@@ -26,36 +26,21 @@ function DataSourceInfo({
const isEnabled =
activeLicense && activeLicense.platform === LicensePlatform.CLOUD;
const {
data: deploymentsData,
isError: isErrorDeploymentsData,
} = useGetDeploymentsData(isEnabled || false);
const { data: hostsData, isError } = useGetHosts({
query: { enabled: isEnabled || false },
});
const [region, setRegion] = useState<string>('');
const [url, setUrl] = useState<string>('');
useEffect(() => {
if (deploymentsData) {
switch (deploymentsData?.data.data.cluster.region.name) {
case 'in':
setRegion('India');
break;
case 'us':
setRegion('United States');
break;
case 'eu':
setRegion('Europe');
break;
default:
setRegion(deploymentsData?.data.data.cluster.region.name);
break;
if (hostsData) {
const defaultHost = hostsData?.data?.data?.hosts?.find((h) => h.is_default);
if (defaultHost?.url) {
const url = defaultHost?.url?.split('://')[1] ?? '';
setUrl(url);
}
setUrl(
`${deploymentsData?.data.data.name}.${deploymentsData?.data.data.cluster.region.dns}`,
);
}
}, [deploymentsData]);
}, [hostsData]);
const renderNotSendingData = (): JSX.Element => (
<>
@@ -123,14 +108,8 @@ function DataSourceInfo({
</Button>
</div>
{!isErrorDeploymentsData && deploymentsData && (
{!isError && hostsData && (
<div className="workspace-details">
<div className="workspace-region">
<Globe size={10} />
<Typography>{region}</Typography>
</div>
<div className="workspace-url">
<Link2 size={12} />
@@ -156,17 +135,11 @@ function DataSourceInfo({
Hello there, Welcome to your SigNoz workspace
</Typography>
{!isErrorDeploymentsData && deploymentsData && (
{!isError && hostsData && (
<Card className="welcome-card">
<Card.Content>
<div className="workspace-ready-container">
<div className="workspace-details">
<div className="workspace-region">
<Globe size={10} />
<Typography>{region}</Typography>
</div>
<div className="workspace-url">
<Link2 size={12} />

View File

@@ -0,0 +1,69 @@
import { GetHosts200 } from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import DataSourceInfo from '../DataSourceInfo';
const ZEUS_HOSTS_ENDPOINT = '*/api/v2/zeus/hosts';
const mockHostsResponse: GetHosts200 = {
status: 'success',
data: {
name: 'accepted-starfish',
state: 'HEALTHY',
tier: 'PREMIUM',
hosts: [
{
name: 'accepted-starfish',
is_default: true,
url: 'https://accepted-starfish.test.cloud',
},
{
name: 'custom-host',
is_default: false,
url: 'https://custom-host.test.cloud',
},
],
},
};
describe('DataSourceInfo', () => {
afterEach(() => server.resetHandlers());
it('renders the default workspace URL with protocol stripped', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
render(<DataSourceInfo dataSentToSigNoz={false} isLoading={false} />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
});
it('does not render workspace URL when GET /zeus/hosts fails', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json({})),
),
);
render(<DataSourceInfo dataSentToSigNoz={false} isLoading={false} />);
await screen.findByText(/Your workspace is ready/i);
expect(screen.queryByText(/signoz\.cloud/i)).not.toBeInTheDocument();
});
it('renders workspace URL in the data-received view when telemetry is flowing', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
render(<DataSourceInfo dataSentToSigNoz={true} isLoading={false} />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
});
});

View File

@@ -36,24 +36,6 @@ jest.mock('react-router-dom', () => {
};
});
// Mock deployments data hook to avoid unrelated network calls in this page
jest.mock(
'hooks/CustomDomain/useGetDeploymentsData',
(): Record<string, unknown> => ({
useGetDeploymentsData: (): {
data: undefined;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
} => ({
data: undefined,
isLoading: false,
isFetching: false,
isError: false,
}),
}),
);
const TEST_CREATED_UPDATED = '2024-01-01T00:00:00Z';
const TEST_EXPIRES_AT = '2030-01-01T00:00:00Z';
const TEST_WORKSPACE_ID = 'w1';

View File

@@ -26,7 +26,7 @@ jest.mock('lib/history', () => ({
// API Endpoints
const ORG_PREFERENCES_ENDPOINT = '*/api/v1/org/preferences/list';
const UPDATE_ORG_PREFERENCE_ENDPOINT = '*/api/v1/org/preferences/name/update';
const UPDATE_PROFILE_ENDPOINT = '*/api/gateway/v2/profiles/me';
const UPDATE_PROFILE_ENDPOINT = '*/api/v2/zeus/profiles';
const EDIT_ORG_ENDPOINT = '*/api/v2/orgs/me';
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk/create';
@@ -277,6 +277,46 @@ describe('OnboardingQuestionaire Component', () => {
).toBeInTheDocument();
});
it('fires PUT to /zeus/profiles and advances to step 4 on success', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let profilePutCalled = false;
server.use(
rest.put(UPDATE_PROFILE_ENDPOINT, (_, res, ctx) => {
profilePutCalled = true;
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
render(<OnboardingQuestionaire />);
// Navigate to step 3
await user.click(screen.getByLabelText(/datadog/i));
await user.click(screen.getByRole('radio', { name: /yes/i }));
await user.click(screen.getByLabelText(/just exploring/i));
await user.click(screen.getByRole('button', { name: /next/i }));
await user.type(
await screen.findByPlaceholderText(/e\.g\., googling/i),
'Found via Google',
);
await user.click(screen.getByLabelText(/lowering observability costs/i));
await user.click(screen.getByRole('button', { name: /next/i }));
// Click "I'll do this later" on step 3 — triggers PUT /zeus/profiles
await user.click(
await screen.findByRole('button', { name: /i'll do this later/i }),
);
await waitFor(() => {
expect(profilePutCalled).toBe(true);
// Step 3 content is gone — successfully advanced to step 4
expect(
screen.queryByText(/what does your scale approximately look like/i),
).not.toBeInTheDocument();
});
});
it('shows do later button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<OnboardingQuestionaire />);

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { toast } from '@signozhq/sonner';
import { NotificationInstance } from 'antd/es/notification/interface';
import logEvent from 'api/common/logEvent';
import updateProfileAPI from 'api/onboarding/updateProfile';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { usePutProfile } from 'api/generated/services/zeus';
import listOrgPreferences from 'api/v1/org/preferences/list';
import updateOrgPreferenceAPI from 'api/v1/org/preferences/name/update';
import { AxiosError } from 'axios';
@@ -121,20 +123,9 @@ function OnboardingQuestionaire(): JSX.Element {
optimiseSignozDetails.hostsPerDay === 0 &&
optimiseSignozDetails.services === 0;
const { mutate: updateProfile, isLoading: isUpdatingProfile } = useMutation(
updateProfileAPI,
{
onSuccess: () => {
setCurrentStep(4);
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
// Allow user to proceed even if API fails
setCurrentStep(4);
},
},
);
const { mutate: updateProfile, isLoading: isUpdatingProfile } = usePutProfile<
AxiosError<RenderErrorResponseDTO>
>();
const { mutate: updateOrgPreference } = useMutation(updateOrgPreferenceAPI, {
onSuccess: () => {
@@ -153,29 +144,44 @@ function OnboardingQuestionaire(): JSX.Element {
nextPageID: 4,
});
updateProfile({
uses_otel: orgDetails?.usesOtel as boolean,
has_existing_observability_tool: orgDetails?.usesObservability as boolean,
existing_observability_tool:
orgDetails?.observabilityTool === 'Others'
? (orgDetails?.otherTool as string)
: (orgDetails?.observabilityTool as string),
where_did_you_discover_signoz: signozDetails?.discoverSignoz as string,
timeline_for_migrating_to_signoz: orgDetails?.migrationTimeline as string,
reasons_for_interest_in_signoz: signozDetails?.interestInSignoz?.includes(
'Others',
)
? ([
...(signozDetails?.interestInSignoz?.filter(
(item) => item !== 'Others',
) || []),
signozDetails?.otherInterestInSignoz,
] as string[])
: (signozDetails?.interestInSignoz as string[]),
logs_scale_per_day_in_gb: optimiseSignozDetails?.logsPerDay as number,
number_of_hosts: optimiseSignozDetails?.hostsPerDay as number,
number_of_services: optimiseSignozDetails?.services as number,
});
updateProfile(
{
data: {
uses_otel: orgDetails?.usesOtel as boolean,
has_existing_observability_tool: orgDetails?.usesObservability as boolean,
existing_observability_tool:
orgDetails?.observabilityTool === 'Others'
? (orgDetails?.otherTool as string)
: (orgDetails?.observabilityTool as string),
where_did_you_discover_signoz: signozDetails?.discoverSignoz as string,
timeline_for_migrating_to_signoz: orgDetails?.migrationTimeline as string,
reasons_for_interest_in_signoz: signozDetails?.interestInSignoz?.includes(
'Others',
)
? ([
...(signozDetails?.interestInSignoz?.filter(
(item) => item !== 'Others',
) || []),
signozDetails?.otherInterestInSignoz,
] as string[])
: (signozDetails?.interestInSignoz as string[]),
logs_scale_per_day_in_gb: optimiseSignozDetails?.logsPerDay as number,
number_of_hosts: optimiseSignozDetails?.hostsPerDay as number,
number_of_services: optimiseSignozDetails?.services as number,
},
},
{
onSuccess: () => {
setCurrentStep(4);
},
onError: (error: any) => {
toast.error(error?.message || SOMETHING_WENT_WRONG);
// Allow user to proceed even if API fails
setCurrentStep(4);
},
},
);
};
const handleOnboardingComplete = (): void => {

View File

@@ -0,0 +1,246 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import useUrlQuery from 'hooks/useUrlQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useTimezone } from 'providers/Timezone';
import { toAPIError } from 'utils/errorUtils';
import '../RolesSettings.styles.scss';
const PAGE_SIZE = 20;
type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: RoletypesRoleDTO };
interface RolesListingTableProps {
searchQuery: string;
}
function RolesListingTable({
searchQuery,
}: RolesListingTableProps): JSX.Element {
const { data, isLoading, isError, error } = useListRoles();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
const setCurrentPage = useCallback(
(page: number): void => {
urlQuery.set('page', String(page));
history.replace({ search: urlQuery.toString() });
},
[history, urlQuery],
);
const roles = useMemo(() => data?.data?.data ?? [], [data]);
const formatTimestamp = (date?: Date | string): string => {
if (!date) {
return '—';
}
const d = new Date(date);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
};
const filteredRoles = useMemo(() => {
if (!searchQuery.trim()) {
return roles;
}
const query = searchQuery.toLowerCase();
return roles.filter(
(role) =>
role.name?.toLowerCase().includes(query) ||
role.description?.toLowerCase().includes(query),
);
}, [roles, searchQuery]);
const managedRoles = useMemo(
() => filteredRoles.filter((role) => role.type?.toLowerCase() === 'managed'),
[filteredRoles],
);
const customRoles = useMemo(
() => filteredRoles.filter((role) => role.type?.toLowerCase() === 'custom'),
[filteredRoles],
);
// Combine managed + custom into a flat display list for pagination
const displayList = useMemo((): DisplayItem[] => {
const result: DisplayItem[] = [];
if (managedRoles.length > 0) {
result.push({ type: 'section', label: 'Managed roles' });
managedRoles.forEach((role) => result.push({ type: 'role', role }));
}
if (customRoles.length > 0) {
result.push({
type: 'section',
label: 'Custom roles',
count: customRoles.length,
});
customRoles.forEach((role) => result.push({ type: 'role', role }));
}
return result;
}, [managedRoles, customRoles]);
const totalRoleCount = managedRoles.length + customRoles.length;
// Ensure current page is valid; if out of bounds, redirect to last available page
useEffect(() => {
if (isLoading || totalRoleCount === 0) {
return;
}
const maxPage = Math.ceil(totalRoleCount / PAGE_SIZE);
if (currentPage > maxPage) {
setCurrentPage(maxPage);
}
}, [isLoading, totalRoleCount, currentPage, setCurrentPage]);
// Paginate: count only role items, but include section headers contextually
const paginatedItems = useMemo((): DisplayItem[] => {
const startRole = (currentPage - 1) * PAGE_SIZE;
const endRole = startRole + PAGE_SIZE;
let roleIndex = 0;
let lastSection: DisplayItem | null = null;
const result: DisplayItem[] = [];
for (const item of displayList) {
if (item.type === 'section') {
lastSection = item;
} else {
if (roleIndex >= startRole && roleIndex < endRole) {
// Insert section header before first role in that section on this page
if (lastSection) {
result.push(lastSection);
lastSection = null;
}
result.push(item);
}
roleIndex++;
}
}
return result;
}, [displayList, currentPage]);
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
<>
<span className="numbers">
{range[0]} &#8212; {range[1]}
</span>
<span className="total"> of {total}</span>
</>
);
if (isLoading) {
return (
<div className="roles-listing-table">
<Skeleton active paragraph={{ rows: 5 }} />
</div>
);
}
if (isError) {
return (
<div className="roles-listing-table">
<ErrorInPlace
error={toAPIError(
error,
'An unexpected error occurred while fetching roles.',
)}
/>
</div>
);
}
if (filteredRoles.length === 0) {
return (
<div className="roles-listing-table">
<div className="roles-table-empty">
{searchQuery ? 'No roles match your search.' : 'No roles found.'}
</div>
</div>
);
}
// todo: use table from periscope when its available for consumption
const renderRow = (role: RoletypesRoleDTO): JSX.Element => (
<div key={role.id} className="roles-table-row">
<div className="roles-table-cell roles-table-cell--name">
{role.name ?? '—'}
</div>
<div className="roles-table-cell roles-table-cell--description">
<LineClampedText
text={role.description ?? '—'}
tooltipProps={{ overlayClassName: 'roles-description-tooltip' }}
/>
</div>
<div className="roles-table-cell roles-table-cell--updated-at">
{formatTimestamp(role.updatedAt)}
</div>
<div className="roles-table-cell roles-table-cell--created-at">
{formatTimestamp(role.createdAt)}
</div>
</div>
);
return (
<div className="roles-listing-table">
<div className="roles-table-scroll-container">
<div className="roles-table-inner">
<div className="roles-table-header">
<div className="roles-table-header-cell roles-table-header-cell--name">
Name
</div>
<div className="roles-table-header-cell roles-table-header-cell--description">
Description
</div>
<div className="roles-table-header-cell roles-table-header-cell--updated-at">
Updated At
</div>
<div className="roles-table-header-cell roles-table-header-cell--created-at">
Created At
</div>
</div>
{paginatedItems.map((item) =>
item.type === 'section' ? (
<h3 key={`section-${item.label}`} className="roles-table-section-header">
{item.label}
{item.count !== undefined && (
<span className="roles-table-section-header__count">{item.count}</span>
)}
</h3>
) : (
renderRow(item.role)
),
)}
</div>
</div>
<Pagination
current={currentPage}
pageSize={PAGE_SIZE}
total={totalRoleCount}
showTotal={showPaginationItem}
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => setCurrentPage(page)}
className="roles-table-pagination"
/>
</div>
);
}
export default RolesListingTable;

View File

@@ -0,0 +1,238 @@
.roles-settings {
.roles-settings-header {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 16px;
.roles-settings-header-title {
color: var(--text-base-white);
font-family: Inter;
font-style: normal;
font-size: 18px;
font-weight: 500;
line-height: 28px;
letter-spacing: -0.09px;
}
.roles-settings-header-description {
color: var(--foreground);
font-family: Inter;
font-style: normal;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
}
.roles-settings-content {
padding: 0 16px;
}
// todo: https://github.com/SigNoz/components/issues/116
.roles-search-wrapper {
input {
width: 100%;
background: var(--l3-background);
border: 1px solid var(--l3-border);
border-radius: 2px;
padding: 6px 6px 6px 8px;
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
outline: none;
height: 32px;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--input);
}
}
}
}
.roles-description-tooltip {
max-height: none;
overflow-y: visible;
}
.roles-listing-table {
margin-top: 12px;
border-radius: 4px;
overflow: hidden;
}
.roles-table-scroll-container {
overflow-x: auto;
}
.roles-table-inner {
min-width: 850px;
}
.roles-table-header {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 24px;
}
.roles-table-header-cell {
font-family: Inter;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.44px;
color: var(--foreground);
line-height: 16px;
&--name {
flex: 0 0 180px;
}
&--description {
flex: 1;
min-width: 0;
}
&--created-at {
flex: 0 0 180px;
text-align: right;
}
&--updated-at {
flex: 0 0 180px;
text-align: right;
}
}
.roles-table-section-header {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 16px;
background: rgba(171, 189, 255, 0.04);
border-top: 1px solid var(--secondary);
border-bottom: 1px solid var(--secondary);
font-family: Inter;
font-size: 12px;
font-weight: 400;
color: var(--foreground);
line-height: 16px;
margin: 0;
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
background: var(--l3-background);
font-size: 12px;
font-weight: 500;
color: var(--foreground);
line-height: 1;
}
}
.roles-table-row {
display: flex;
align-items: center;
padding: 8px 16px;
background: rgba(171, 189, 255, 0.02);
border-bottom: 1px solid var(--secondary);
gap: 24px;
}
.roles-table-cell {
font-family: Inter;
font-size: 14px;
font-weight: 400;
color: var(--foreground);
line-height: 20px;
&--name {
flex: 0 0 180px;
font-weight: 500;
}
&--description {
flex: 1;
min-width: 0;
overflow: hidden;
}
&--created-at {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
&--updated-at {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
}
.roles-table-empty {
padding: 24px 16px;
text-align: center;
color: var(--foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
.roles-table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 16px;
.ant-pagination-total-text {
margin-right: auto;
.numbers {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
}
.total {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
opacity: 0.5;
}
}
}
.lightMode {
.roles-settings {
.roles-settings-header {
.roles-settings-header-title {
color: var(--text-base-black);
}
}
}
.roles-table-section-header {
background: rgba(0, 0, 0, 0.03);
}
.roles-table-row {
background: rgba(0, 0, 0, 0.01);
}
}

View File

@@ -0,0 +1,34 @@
import { useState } from 'react';
import { Input } from '@signozhq/input';
import RolesListingTable from './RolesComponents/RolesListingTable';
import './RolesSettings.styles.scss';
function RolesSettings(): JSX.Element {
const [searchQuery, setSearchQuery] = useState('');
return (
<div className="roles-settings" data-testid="roles-settings">
<div className="roles-settings-header">
<h3 className="roles-settings-header-title">Roles</h3>
<p className="roles-settings-header-description">
Create and manage custom roles for your team.
</p>
</div>
<div className="roles-settings-content">
<div className="roles-search-wrapper">
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
</div>
<RolesListingTable searchQuery={searchQuery} />
</div>
</div>
);
}
export default RolesSettings;

View File

@@ -0,0 +1,232 @@
import {
allRoles,
listRolesSuccessResponse,
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent } from 'tests/test-utils';
import RolesSettings from '../RolesSettings';
const rolesApiURL = 'http://localhost/api/v1/roles';
describe('RolesSettings', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the header and search input', () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(screen.getByText('Roles')).toBeInTheDocument();
expect(
screen.getByText('Create and manage custom roles for your team.'),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search for roles...'),
).toBeInTheDocument();
});
it('displays roles grouped by managed and custom sections', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
// Section headers
expect(screen.getByText('Managed roles')).toBeInTheDocument();
expect(screen.getByText('Custom roles')).toBeInTheDocument();
// Managed roles
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
expect(screen.getByText('signoz-editor')).toBeInTheDocument();
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
// Custom roles
expect(screen.getByText('billing-manager')).toBeInTheDocument();
expect(screen.getByText('dashboard-creator')).toBeInTheDocument();
// Custom roles count badge
expect(screen.getByText('2')).toBeInTheDocument();
// Column headers
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Updated At')).toBeInTheDocument();
expect(screen.getByText('Created At')).toBeInTheDocument();
});
it('filters roles by search query on name', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'billing');
expect(await screen.findByText('billing-manager')).toBeInTheDocument();
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
expect(screen.queryByText('signoz-editor')).not.toBeInTheDocument();
expect(screen.queryByText('dashboard-creator')).not.toBeInTheDocument();
});
it('filters roles by search query on description', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'read-only');
expect(await screen.findByText('signoz-viewer')).toBeInTheDocument();
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
expect(screen.queryByText('billing-manager')).not.toBeInTheDocument();
});
it('shows empty state when search matches nothing', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'nonexistentrole');
expect(
await screen.findByText('No roles match your search.'),
).toBeInTheDocument();
});
it('shows loading skeleton while fetching', () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.delay(200), ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('shows error state when API fails', async () => {
const errorMessage = 'Failed to fetch roles';
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(
ctx.status(500),
ctx.json({
error: {
code: 'INTERNAL_ERROR',
message: errorMessage,
url: '',
errors: [],
},
}),
),
),
);
render(<RolesSettings />);
expect(await screen.findByText(errorMessage)).toBeInTheDocument();
});
it('shows empty state when API returns no roles', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: [] })),
),
);
render(<RolesSettings />);
expect(await screen.findByText('No roles found.')).toBeInTheDocument();
});
it('renders descriptions for all roles', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
for (const role of allRoles) {
if (role.description) {
expect(screen.getByText(role.description)).toBeInTheDocument();
}
}
});
it('handles invalid dates gracefully by showing fallback', async () => {
const invalidRole = {
id: 'edge-0009',
createdAt: ('invalid-date' as unknown) as Date,
updatedAt: ('not-a-date' as unknown) as Date,
name: 'invalid-date-role',
description: 'Tests date parsing fallback.',
type: 'custom',
orgId: 'org-001',
};
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: [invalidRole],
}),
),
),
);
render(<RolesSettings />);
expect(await screen.findByText('invalid-date-role')).toBeInTheDocument();
// Verify the "—" (em-dash) fallback is shown for both cells
const dashFallback = screen.getAllByText('—');
// In renderRow: name, description, updatedAt, createdAt.
// Total dashes expected: 2 (for both dates)
expect(dashFallback.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1 @@
export { default } from './RolesSettings';

View File

@@ -28,6 +28,7 @@ import {
ScrollText,
Search,
Settings,
Shield,
Slack,
Unplug,
User,
@@ -312,6 +313,13 @@ export const settingsMenuItems: SidebarItem[] = [
isEnabled: false,
itemKey: 'billing',
},
{
key: ROUTES.ROLES_SETTINGS,
label: 'Roles',
icon: <Shield size={16} />,
isEnabled: false,
itemKey: 'roles',
},
{
key: ROUTES.ORG_SETTINGS,
label: 'Members & SSO',

View File

@@ -159,6 +159,7 @@ export const routesToSkip = [
ROUTES.ERROR_DETAIL,
ROUTES.LOGS_PIPELINES,
ROUTES.BILLING,
ROUTES.ROLES_SETTINGS,
ROUTES.SUPPORT,
ROUTES.WORKSPACE_LOCKED,
ROUTES.WORKSPACE_SUSPENDED,

View File

@@ -1,13 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import { getDeploymentsData } from 'api/customDomain/getDeploymentsData';
import { AxiosError, AxiosResponse } from 'axios';
import { DeploymentsDataProps } from 'types/api/customDomain/types';
export const useGetDeploymentsData = (
isEnabled: boolean,
): UseQueryResult<AxiosResponse<DeploymentsDataProps>, AxiosError> =>
useQuery<AxiosResponse<DeploymentsDataProps>, AxiosError>({
queryKey: ['getDeploymentsData'],
queryFn: () => getDeploymentsData(),
enabled: isEnabled,
});

View File

@@ -0,0 +1,64 @@
import { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
export const managedRoles: RoletypesRoleDTO[] = [
{
id: '019c24aa-2248-756f-9833-984f1ab63819',
createdAt: new Date('2026-02-03T18:00:55.624356Z'),
updatedAt: new Date('2026-02-03T18:00:55.624356Z'),
name: 'signoz-admin',
description:
'Role assigned to users who have full administrative access to SigNoz resources.',
type: 'managed',
orgId,
},
{
id: '019c24aa-2248-757c-9faf-7b1e899751e0',
createdAt: new Date('2026-02-03T18:00:55.624359Z'),
updatedAt: new Date('2026-02-03T18:00:55.624359Z'),
name: 'signoz-editor',
description:
'Role assigned to users who can create, edit, and manage SigNoz resources but do not have full administrative privileges.',
type: 'managed',
orgId,
},
{
id: '019c24aa-2248-7585-a129-4188b3473c27',
createdAt: new Date('2026-02-03T18:00:55.624362Z'),
updatedAt: new Date('2026-02-03T18:00:55.624362Z'),
name: 'signoz-viewer',
description:
'Role assigned to users who have read-only access to SigNoz resources.',
type: 'managed',
orgId,
},
];
export const customRoles: RoletypesRoleDTO[] = [
{
id: '019c24aa-3333-0001-aaaa-111111111111',
createdAt: new Date('2026-02-10T10:30:00.000Z'),
updatedAt: new Date('2026-02-12T14:20:00.000Z'),
name: 'billing-manager',
description: 'Custom role for managing billing and invoices.',
type: 'custom',
orgId,
},
{
id: '019c24aa-3333-0002-bbbb-222222222222',
createdAt: new Date('2026-02-11T09:00:00.000Z'),
updatedAt: new Date('2026-02-13T11:45:00.000Z'),
name: 'dashboard-creator',
description: 'Custom role allowing users to create and manage dashboards.',
type: 'custom',
orgId,
},
];
export const allRoles: RoletypesRoleDTO[] = [...managedRoles, ...customRoles];
export const listRolesSuccessResponse = {
status: 'success',
data: allRoles,
};

View File

@@ -77,6 +77,7 @@ function SettingsPage(): JSX.Element {
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.CUSTOM_DOMAIN_SETTINGS ||
item.key === ROUTES.API_KEYS ||
@@ -107,6 +108,7 @@ function SettingsPage(): JSX.Element {
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
@@ -134,7 +136,9 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.API_KEYS || item.key === ROUTES.ORG_SETTINGS
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.ROLES_SETTINGS
? true
: item.isEnabled,
}));

View File

@@ -12,6 +12,7 @@ import IngestionSettings from 'container/IngestionSettings/IngestionSettings';
import MultiIngestionSettings from 'container/IngestionSettings/MultiIngestionSettings';
import MySettings from 'container/MySettings';
import OrganizationSettings from 'container/OrganizationSettings';
import RolesSettings from 'container/RolesSettings';
import { TFunction } from 'i18next';
import {
Backpack,
@@ -24,6 +25,7 @@ import {
KeySquare,
Pencil,
Plus,
Shield,
User,
} from 'lucide-react';
import ChannelsEdit from 'pages/ChannelsEdit';
@@ -148,6 +150,19 @@ export const billingSettings = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const rolesSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: RolesSettings,
name: (
<div className="periscope-tab">
<Shield size={16} /> {t('routes:roles').toString()}
</div>
),
route: ROUTES.ROLES_SETTINGS,
key: ROUTES.ROLES_SETTINGS,
},
];
export const keyboardShortcuts = (t: TFunction): RouteTabProps['routes'] => [
{
Component: Shortcuts,

View File

@@ -15,6 +15,7 @@ import {
multiIngestionSettings,
mySettings,
organizationSettings,
rolesSettings,
} from './config';
export const getRoutes = (
@@ -66,6 +67,10 @@ export const getRoutes = (
settings.push(...customDomainSettings(t), ...billingSettings(t));
}
if (isAdmin) {
settings.push(...rolesSettings(t));
}
settings.push(
...mySettings(t),
...createAlertChannels(t),

View File

@@ -1,53 +0,0 @@
export interface HostsProps {
name: string;
is_default: boolean;
}
export interface RegionProps {
id: string;
name: string;
category: string;
dns: string;
created_at: string;
updated_at: string;
}
export interface ClusterProps {
id: string;
name: string;
cloud_account_id: string;
cloud_region: string;
address: string;
region: RegionProps;
}
export interface DeploymentData {
id: string;
name: string;
email: string;
state: string;
tier: string;
user: string;
password: string;
created_at: string;
updated_at: string;
cluster_id: string;
hosts: HostsProps[];
cluster: ClusterProps;
}
export interface DeploymentsDataProps {
status: string;
data: DeploymentData;
}
export type PayloadProps = {
status: string;
data: string;
};
export interface UpdateCustomDomainProps {
data: {
name: string;
};
}

View File

@@ -1,11 +0,0 @@
export interface UpdateProfileProps {
reasons_for_interest_in_signoz: string[];
uses_otel: boolean;
has_existing_observability_tool: boolean;
existing_observability_tool: string;
logs_scale_per_day_in_gb: number;
number_of_services: number;
number_of_hosts: number;
where_did_you_discover_signoz: string;
timeline_for_migrating_to_signoz: string;
}

View File

@@ -1,3 +1,8 @@
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
/**
* Extracts HTTP status code from various error types
* @param error - The error object (could be APIError, AxiosError, or other error types)
@@ -28,3 +33,25 @@ export const isRetryableError = (error: any): boolean => {
// If no status code is available, default to retryable
return !statusCode || statusCode >= 500;
};
export function toAPIError(
error: unknown,
defaultMessage = 'An unexpected error occurred.',
): APIError {
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
if (apiError instanceof APIError) {
return apiError;
}
}
return new APIError({
httpStatusCode: 500,
error: {
code: 'UNKNOWN_ERROR',
message: defaultMessage,
url: '',
errors: [],
},
});
}

View File

@@ -97,6 +97,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -22,6 +22,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/gorilla/mux"
)
@@ -44,6 +45,7 @@ type provider struct {
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
authzHandler authz.Handler
zeusHandler zeus.Handler
}
func NewFactory(
@@ -63,6 +65,7 @@ func NewFactory(
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
authzHandler authz.Handler,
zeusHandler zeus.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -85,6 +88,7 @@ func NewFactory(
gatewayHandler,
fieldsHandler,
authzHandler,
zeusHandler,
)
})
}
@@ -109,6 +113,7 @@ func newProvider(
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
authzHandler authz.Handler,
zeusHandler zeus.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -131,6 +136,7 @@ func newProvider(
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
authzHandler: authzHandler,
zeusHandler: zeusHandler,
}
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
@@ -199,6 +205,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addZeusRoutes(router); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,65 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/gorilla/mux"
)
func (provider *provider) addZeusRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/zeus/profiles", handler.New(provider.authZ.AdminAccess(provider.zeusHandler.PutProfile), handler.OpenAPIDef{
ID: "PutProfile",
Tags: []string{"zeus"},
Summary: "Put profile in Zeus for a deployment.",
Description: "This endpoint saves the profile of a deployment to zeus.",
Request: new(zeustypes.PostableProfile),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authZ.AdminAccess(provider.zeusHandler.GetHosts), handler.OpenAPIDef{
ID: "GetHosts",
Tags: []string{"zeus"},
Summary: "Get host info from Zeus.",
Description: "This endpoint gets the host info from zeus.",
Request: nil,
RequestContentType: "",
Response: new(zeustypes.GettableHost),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authZ.AdminAccess(provider.zeusHandler.PutHost), handler.OpenAPIDef{
ID: "PutHost",
Tags: []string{"zeus"},
Summary: "Put host in Zeus for a deployment.",
Description: "This endpoint saves the host of a deployment to zeus.",
Request: new(zeustypes.PostableHost),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -31,6 +31,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/zeus"
)
type Handlers struct {
@@ -48,6 +49,7 @@ type Handlers struct {
GatewayHandler gateway.Handler
Fields fields.Handler
AuthzHandler authz.Handler
ZeusHandler zeus.Handler
}
func NewHandlers(
@@ -60,6 +62,7 @@ func NewHandlers(
gatewayService gateway.Gateway,
telemetryMetadataStore telemetrytypes.MetadataStore,
authz authz.AuthZ,
zeusService zeus.Zeus,
) Handlers {
return Handlers{
SavedView: implsavedview.NewHandler(modules.SavedView),
@@ -76,5 +79,6 @@ func NewHandlers(
GatewayHandler: gateway.NewHandler(gatewayService),
Fields: implfields.NewHandler(providerSettings, telemetryMetadataStore),
AuthzHandler: signozauthzapi.NewHandler(authz),
ZeusHandler: zeus.NewHandler(zeusService, licensing),
}
}

View File

@@ -42,7 +42,7 @@ func TestNewHandlers(t *testing.T) {
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule)
handlers := NewHandlers(modules, providerSettings, nil, nil, nil, nil, nil, nil, nil)
handlers := NewHandlers(modules, providerSettings, nil, nil, nil, nil, nil, nil, nil, nil)
reflectVal := reflect.ValueOf(handlers)
for i := 0; i < reflectVal.NumField(); i++ {
f := reflectVal.Field(i)

View File

@@ -28,6 +28,7 @@ import (
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/gorilla/mux"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/swaggest/jsonschema-go"
"github.com/swaggest/openapi-go"
"github.com/swaggest/openapi-go/openapi3"
@@ -58,6 +59,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ gateway.Handler }{},
struct{ fields.Handler }{},
struct{ authz.Handler }{},
struct{ zeus.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err

View File

@@ -252,6 +252,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.GatewayHandler,
handlers.Fields,
handlers.AuthzHandler,
handlers.ZeusHandler,
),
)
}

View File

@@ -392,7 +392,7 @@ func New(
userService := impluser.NewService(providerSettings, impluser.NewStore(sqlstore, providerSettings), modules.User, orgGetter, authz, config.User.Root)
// Initialize all handlers for the modules
handlers := NewHandlers(modules, providerSettings, querier, licensing, global, flagger, gateway, telemetryMetadataStore, authz)
handlers := NewHandlers(modules, providerSettings, querier, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus)
// Initialize the API server
apiserver, err := factory.NewProviderFromNamedMap(

View File

@@ -40,7 +40,6 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
return nil, err
}
hooks := make([]telemetrystore.TelemetryStoreHook, len(hookFactories))
for i, hookFactory := range hookFactories {
hook, err := hookFactory.New(ctx, providerSettings, config)
@@ -83,11 +82,18 @@ func (p *provider) Query(ctx context.Context, query string, args ...interface{})
ctx = telemetrystore.WrapBeforeQuery(p.hooks, ctx, event)
rows, err := p.clickHouseConn.Query(ctx, query, args...)
if err != nil {
event.Err = err
telemetrystore.WrapAfterQuery(p.hooks, ctx, event)
return nil, err
}
event.Err = err
telemetrystore.WrapAfterQuery(p.hooks, ctx, event)
return rows, err
return &rowsWithHooks{
Rows: rows,
ctx: ctx,
event: event,
onClose: func() { telemetrystore.WrapAfterQuery(p.hooks, ctx, event) },
}, nil
}
func (p *provider) QueryRow(ctx context.Context, query string, args ...interface{}) driver.Row {

View File

@@ -0,0 +1,37 @@
package clickhousetelemetrystore
import (
"context"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/telemetrystore"
)
// rowsWithHooks wraps driver.Rows and defers AfterQuery hooks until Close(),
// so the instrumentation span covers the full query lifecycle including row consumption.
type rowsWithHooks struct {
driver.Rows
ctx context.Context
event *telemetrystore.QueryEvent
onClose func()
closed bool
}
func (r *rowsWithHooks) Close() error {
// delegate to the original rows.Close() if already closed
if r.closed {
return r.Rows.Close()
}
// mark as closed and run the onClose hook
r.closed = true
if err := r.Rows.Err(); err != nil {
r.event.Err = err
}
closeErr := r.Rows.Close()
if closeErr != nil {
r.event.Err = closeErr
}
r.onClose()
return closeErr
}

View File

@@ -0,0 +1,58 @@
package zeustypes
import (
"net/url"
"github.com/tidwall/gjson"
)
type PostableHost struct {
Name string `json:"name" required:"true"`
}
type PostableProfile struct {
UsesOtel bool `json:"uses_otel"`
HasExistingObservabilityTool bool `json:"has_existing_observability_tool"`
ExistingObservabilityTool string `json:"existing_observability_tool"`
ReasonsForInterestInSigNoz []string `json:"reasons_for_interest_in_signoz"`
LogsScalePerDayInGB int64 `json:"logs_scale_per_day_in_gb"`
NumberOfServices int64 `json:"number_of_services"`
NumberOfHosts int64 `json:"number_of_hosts"`
WhereDidYouDiscoverSigNoz string `json:"where_did_you_discover_signoz"`
TimelineForMigratingToSigNoz string `json:"timeline_for_migrating_to_signoz"`
}
type GettableHost struct {
Name string `json:"name"`
State string `json:"state"`
Tier string `json:"tier"`
Hosts []Host `json:"hosts"`
}
type Host struct {
Name string `json:"name"`
IsDefault bool `json:"is_default"`
URL string `json:"url"`
}
func NewGettableHost(data []byte) *GettableHost {
parsed := gjson.ParseBytes(data)
dns := parsed.Get("cluster.region.dns").String()
hostResults := parsed.Get("hosts").Array()
hosts := make([]Host, len(hostResults))
for i, h := range hostResults {
name := h.Get("name").String()
hosts[i].Name = name
hosts[i].IsDefault = h.Get("is_default").Bool()
hosts[i].URL = (&url.URL{Scheme: "https", Host: name + "." + dns}).String()
}
return &GettableHost{
Name: parsed.Get("name").String(),
State: parsed.Get("state").String(),
Tier: parsed.Get("tier").String(),
Hosts: hosts,
}
}

114
pkg/zeus/handler.go Normal file
View File

@@ -0,0 +1,114 @@
package zeus
import (
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type handler struct {
zeus Zeus
licensing licensing.Licensing
}
func NewHandler(zeus Zeus, licensing licensing.Licensing) Handler {
return &handler{
zeus: zeus,
licensing: licensing,
}
}
func (h *handler) PutProfile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
license, err := h.licensing.GetActive(ctx, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(rw, err)
return
}
req := new(zeustypes.PostableProfile)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
if err := h.zeus.PutProfile(ctx, license.Key, req); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (h *handler) GetHosts(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
license, err := h.licensing.GetActive(ctx, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(rw, err)
return
}
deploymentBytes, err := h.zeus.GetDeployment(ctx, license.Key)
if err != nil {
render.Error(rw, err)
return
}
response := zeustypes.NewGettableHost(deploymentBytes)
render.Success(rw, http.StatusOK, response)
}
func (h *handler) PutHost(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
license, err := h.licensing.GetActive(ctx, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(rw, err)
return
}
req := new(zeustypes.PostableHost)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
if req.Name == "" {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "name is required"))
return
}
if err := h.zeus.PutHost(ctx, license.Key, req); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/SigNoz/signoz/pkg/zeus"
)
@@ -40,10 +41,10 @@ func (provider *provider) PutMeters(_ context.Context, _ string, _ []byte) error
return errors.New(errors.TypeUnsupported, zeus.ErrCodeUnsupported, "putting meters is not supported")
}
func (provider *provider) PutProfile(_ context.Context, _ string, _ []byte) error {
func (provider *provider) PutProfile(_ context.Context, _ string, _ *zeustypes.PostableProfile) error {
return errors.New(errors.TypeUnsupported, zeus.ErrCodeUnsupported, "putting profile is not supported")
}
func (provider *provider) PutHost(_ context.Context, _ string, _ []byte) error {
func (provider *provider) PutHost(_ context.Context, _ string, _ *zeustypes.PostableHost) error {
return errors.New(errors.TypeUnsupported, zeus.ErrCodeUnsupported, "putting host is not supported")
}

View File

@@ -2,8 +2,10 @@ package zeus
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
)
var (
@@ -28,8 +30,19 @@ type Zeus interface {
PutMeters(context.Context, string, []byte) error
// Put profile for the given license key.
PutProfile(context.Context, string, []byte) error
PutProfile(context.Context, string, *zeustypes.PostableProfile) error
// Put host for the given license key.
PutHost(context.Context, string, []byte) error
PutHost(context.Context, string, *zeustypes.PostableHost) error
}
type Handler interface {
// API level handler for PutProfile
PutProfile(http.ResponseWriter, *http.Request)
// API level handler for getting hosts a slim wrapper around GetDeployment
GetHosts(http.ResponseWriter, *http.Request)
// API level handler for PutHost
PutHost(http.ResponseWriter, *http.Request)
}