Compare commits

..

5 Commits

Author SHA1 Message Date
swapnil-signoz
b3ab952668 feat: generating openapi spec 2026-06-11 19:27:36 +05:30
swapnil-signoz
fb57f69ab5 Merge branch 'issue_5324' into issue_5326 2026-06-11 19:22:34 +05:30
swapnil-signoz
5c03edba64 feat: adding support for Azure SQL DB Managed Instances 2026-06-11 19:21:58 +05:30
swapnil-signoz
8fe3f769c2 refactor: adding missing metrics in data collected and panels in dash 2026-06-11 18:36:05 +05:30
swapnil-signoz
e6d8713e1c feat: adding support for Azure SQL DB 2026-06-11 15:19:52 +05:30
343 changed files with 8850 additions and 26059 deletions

View File

@@ -19,8 +19,5 @@
"editor.defaultFormatter": "vscode.html-language-features"
},
"python-envs.defaultEnvManager": "ms-python.python:system",
"python-envs.pythonProjects": [],
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
"python-envs.pythonProjects": []
}

File diff suppressed because it is too large Load Diff

View File

@@ -109,20 +109,6 @@ func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
}
```
When you need an ID from `claims` as a `valuer.UUID` (for example to pass it to a module), derive it with the `Must*` constructor instead of `NewUUID` plus an error check. Claims are validated by the auth middleware, so the conversion cannot fail and the error branch would be dead code:
```go
// Good — claims are pre-validated, the conversion cannot fail.
orgID := valuer.MustNewUUID(claims.OrgID)
// Avoid — the error path is unreachable.
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
```
### 3. Register the handler in `signozapiserver`
In `pkg/apiserver/signozapiserver`, add a route in the appropriate `add*Routes` function (`addUserRoutes`, `addSessionRoutes`, `addOrgRoutes`, etc.). The pattern is:
@@ -347,50 +333,6 @@ func (Step) JSONSchema() (jsonschema.Schema, error) {
}
```
### `oneOf` with a discriminator
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`).
The parent keeps its `JSONSchemaOneOf()` (the `oneOf` itself) and *additionally* tags it via `PrepareJSONSchema` with the `x-signoz-discriminator` extension; `signoz.attachDiscriminators` then promotes that marker to a real OpenAPI 3 `discriminator` (and strips the duplicate parent properties) after reflection.
```go
// On the parent: expose the oneOf variants...
func (Plugin) JSONSchemaOneOf() []any {
return []any{FooVariant{}}
}
// ...and tag that same oneOf with the discriminator marker.
func (Plugin) PrepareJSONSchema(s *jsonschema.Schema) error {
if s.ExtraProperties == nil {
s.ExtraProperties = map[string]any{}
}
s.ExtraProperties["x-signoz-discriminator"] = map[string]any{
"propertyName": "kind",
"mapping": map[string]string{
"signoz/Foo": "#/components/schemas/FooVariant",
},
}
return nil
}
```
Each variant must declare the discriminator property (`kind`) and mark it `required`.
This produces the following in the generated OpenAPI spec:
```yaml
Plugin:
discriminator:
propertyName: kind
mapping:
signoz/Foo: '#/components/schemas/FooVariant'
oneOf:
- $ref: '#/components/schemas/FooVariant'
type: object
```
Note the discriminator property lives in the variants, not on the parent — the parent is only the union.
## What should I remember?
@@ -401,4 +343,3 @@ Note the discriminator property lives in the variants, not on the parent — the
- **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/apiserver/signozapiserver/querier.go` for reference.
- **Derive IDs from `claims` with `valuer.MustNewUUID`** (e.g. `claims.OrgID`, `claims.UserID`). Claims are pre-validated by the auth middleware, so use the `Must*` constructor — don't write `NewUUID` followed by an `if err != nil { render.Error(...); return }` block.

View File

@@ -229,39 +229,10 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
return module.pkgDashboardModule.DeleteV2(ctx, orgID, id)
})
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
return module.pkgDashboardModule.ListV2(ctx, orgID, params)
}
func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error) {
return module.pkgDashboardModule.ListForUserV2(ctx, orgID, userID, params)
}
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
}
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(ctx, orgID, userID, id)
}
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -3,13 +3,13 @@ package querier
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -48,8 +48,8 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
}
var queryRangeRequest qbtypes.QueryRangeRequest
if err := binding.JSON.BindBody(req.Body, &queryRangeRequest); err != nil {
render.Error(rw, err)
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to decode request body: %v", err))
return
}

View File

@@ -185,7 +185,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)

View File

@@ -16,11 +16,10 @@ func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
}
func (f *formatter) JSONExtractString(column, path string) []byte {
ops := f.convertJSONPathToPostgres(path)
if len(ops) == 0 {
return f.bunf.AppendIdent(nil, column)
}
return append(f.TextToJsonColumn(column), ops...)
var sql []byte
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgres(path)...)
return sql
}
func (f *formatter) JSONType(column, path string) []byte {

View File

@@ -18,19 +18,19 @@ func TestJSONExtractString(t *testing.T) {
name: "simple path",
column: "data",
path: "$.field",
expected: `"data"::jsonb->>'field'`,
expected: `"data"->>'field'`,
},
{
name: "nested path",
column: "metadata",
path: "$.user.name",
expected: `"metadata"::jsonb->'user'->>'name'`,
expected: `"metadata"->'user'->>'name'`,
},
{
name: "deeply nested path",
column: "json_col",
path: "$.level1.level2.level3",
expected: `"json_col"::jsonb->'level1'->'level2'->>'level3'`,
expected: `"json_col"->'level1'->'level2'->>'level3'`,
},
{
name: "root path",

View File

@@ -323,10 +323,3 @@ export const AIAssistantPage = Loadable(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);

View File

@@ -22,7 +22,6 @@ import {
IntegrationsDetailsPage,
LicensePage,
ListAllALertsPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -508,13 +507,6 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -26,7 +26,6 @@ import type {
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeleteDashboardV2PathParameters,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
@@ -36,17 +35,11 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListDashboardsV2200,
ListDashboardsV2Params,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
PinDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UnpinDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
@@ -63,7 +56,7 @@ export const deletePublicDashboard = (
{ id }: DeletePublicDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/dashboards/${id}/public`,
method: 'DELETE',
signal,
@@ -346,7 +339,7 @@ export const updatePublicDashboard = (
dashboardtypesUpdatablePublicDashboardDTO?: BodyType<DashboardtypesUpdatablePublicDashboardDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/dashboards/${id}/public`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -648,103 +641,6 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
* @summary List dashboards (v2)
*/
export const listDashboardsV2 = (
params?: ListDashboardsV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsV2200>({
url: `/api/v2/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsV2QueryKey = (
params?: ListDashboardsV2Params,
) => {
return [`/api/v2/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardsV2QueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listDashboardsV2>>> = ({
signal,
}) => listDashboardsV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsV2>>
>;
export type ListDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards (v2)
*/
export function useListDashboardsV2<
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards (v2)
*/
export const invalidateListDashboardsV2 = async (
queryClient: QueryClient,
params?: ListDashboardsV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a dashboard in the v2 format that follows Perses spec.
* @summary Create dashboard (v2)
@@ -828,85 +724,6 @@ export const useCreateDashboardV2 = <
> => {
return useMutation(getCreateDashboardV2MutationOptions(options));
};
/**
* This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.
* @summary Delete dashboard (v2)
*/
export const deleteDashboardV2 = (
{ id }: DeleteDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/dashboards/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['deleteDashboardV2'];
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 deleteDashboardV2>>,
{ pathParams: DeleteDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof deleteDashboardV2>>
>;
export type DeleteDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete dashboard (v2)
*/
export const useDeleteDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
> => {
return useMutation(getDeleteDashboardV2MutationOptions(options));
};
/**
* This endpoint returns a v2-shape dashboard.
* @summary Get dashboard (v2)
@@ -1214,7 +1031,7 @@ export const unlockDashboardV2 = (
{ id }: UnlockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'DELETE',
signal,
@@ -1293,7 +1110,7 @@ export const lockDashboardV2 = (
{ id }: LockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'PUT',
signal,
@@ -1364,260 +1181,3 @@ export const useLockDashboardV2 = <
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
* @summary List dashboards for the current user (v2)
*/
export const listDashboardsForUserV2 = (
params?: ListDashboardsForUserV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsForUserV2200>({
url: `/api/v2/users/me/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsForUserV2QueryKey = (
params?: ListDashboardsForUserV2Params,
) => {
return [`/api/v2/users/me/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsForUserV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsForUserV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListDashboardsForUserV2QueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listDashboardsForUserV2>>
> = ({ signal }) => listDashboardsForUserV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsForUserV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsForUserV2>>
>;
export type ListDashboardsForUserV2QueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards for the current user (v2)
*/
export function useListDashboardsForUserV2<
TData = Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsForUserV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsForUserV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsForUserV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards for the current user (v2)
*/
export const invalidateListDashboardsForUserV2 = async (
queryClient: QueryClient,
params?: ListDashboardsForUserV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsForUserV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.
* @summary Unpin a dashboard for the current user (v2)
*/
export const unpinDashboardV2 = (
{ id }: UnpinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'DELETE',
signal,
});
};
export const getUnpinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unpinDashboardV2'];
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 unpinDashboardV2>>,
{ pathParams: UnpinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unpinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnpinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unpinDashboardV2>>
>;
export type UnpinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unpin a dashboard for the current user (v2)
*/
export const useUnpinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnpinDashboardV2MutationOptions(options));
};
/**
* Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.
* @summary Pin a dashboard for the current user (v2)
*/
export const pinDashboardV2 = (
{ id }: PinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'PUT',
signal,
});
};
export const getPinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['pinDashboardV2'];
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 pinDashboardV2>>,
{ pathParams: PinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return pinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type PinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof pinDashboardV2>>
>;
export type PinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Pin a dashboard for the current user (v2)
*/
export const usePinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
return useMutation(getPinDashboardV2MutationOptions(options));
};

View File

@@ -37,7 +37,7 @@ export const handleExportRawDataPOST = (
params?: HandleExportRawDataPOSTParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/export_raw_data`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -680,7 +680,7 @@ export const updateMetricMetadata = (
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v2/metrics/${metricName}/metadata`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -203,7 +203,7 @@ export const deleteRole = (
{ id }: DeleteRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/roles/${id}`,
method: 'DELETE',
signal,
@@ -372,7 +372,7 @@ export const patchRole = (
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/roles/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -572,7 +572,7 @@ export const patchObjects = (
coretypesPatchableObjectsDTO?: BodyType<CoretypesPatchableObjectsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },

View File

@@ -222,7 +222,7 @@ export const deleteServiceAccount = (
{ id }: DeleteServiceAccountPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/service_accounts/${id}`,
method: 'DELETE',
signal,
@@ -405,7 +405,7 @@ export const updateServiceAccount = (
serviceaccounttypesPostableServiceAccountDTO?: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/service_accounts/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -707,7 +707,7 @@ export const revokeServiceAccountKey = (
{ id, fid }: RevokeServiceAccountKeyPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
method: 'DELETE',
signal,
@@ -788,7 +788,7 @@ export const updateServiceAccountKey = (
serviceaccounttypesUpdatableFactorAPIKeyDTO?: BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -1090,7 +1090,7 @@ export const deleteServiceAccountRole = (
{ id, rid }: DeleteServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
method: 'DELETE',
signal,
@@ -1254,7 +1254,7 @@ export const updateMyServiceAccount = (
serviceaccounttypesPostableServiceAccountDTO?: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
return GeneratedAPIInstance<string>({
url: `/api/v1/service_accounts/me`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },

View File

@@ -413,11 +413,21 @@ export interface AlertmanagertypesRecurrenceDTO {
* @type string
*/
duration: string;
/**
* @type string,null
* @format date-time
*/
endTime?: string | null;
/**
* @type array,null
*/
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
repeatType: AlertmanagertypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: string;
}
export interface AlertmanagertypesScheduleDTO {
@@ -431,7 +441,7 @@ export interface AlertmanagertypesScheduleDTO {
* @type string
* @format date-time
*/
startTime: string;
startTime?: string;
/**
* @type string
*/
@@ -2143,10 +2153,6 @@ export interface ErrorsResponseerroradditionalDTO {
* @type string
*/
message?: string;
/**
* @type array
*/
suggestions?: string[];
}
export interface ErrorsResponseretryjsonDTO {
@@ -2162,6 +2168,10 @@ export interface ErrorsJSONDTO {
* @type array
*/
errors?: ErrorsResponseerroradditionalDTO[];
/**
* @type array
*/
invalidReferences?: string[];
/**
* @type string
*/
@@ -2645,6 +2655,8 @@ export enum CloudintegrationtypesServiceIDDTO {
appservice = 'appservice',
containerapp = 'containerapp',
aks = 'aks',
sqldatabase = 'sqldatabase',
sqldatabasemi = 'sqldatabasemi',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -3146,6 +3158,17 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface DashboardPanelDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
@@ -3474,9 +3497,6 @@ export interface TelemetrytypesTelemetryFieldKeyDTO {
unit?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
logs = 'logs',
}
export enum TelemetrytypesSourceDTO {
meter = 'meter',
}
@@ -3532,11 +3552,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @enum logs
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal;
signal?: TelemetrytypesSignalDTO;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3602,9 +3618,6 @@ export interface Querybuildertypesv5MetricAggregationDTO {
timeAggregation?: MetrictypesTimeAggregationDTO;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal {
metrics = 'metrics',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO {
/**
* @type array
@@ -3657,11 +3670,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @enum metrics
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal;
signal?: TelemetrytypesSignalDTO;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3677,9 +3686,6 @@ export interface Querybuildertypesv5TraceAggregationDTO {
expression?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal {
traces = 'traces',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO {
/**
* @type array
@@ -3732,11 +3738,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @enum traces
* @type string
*/
signal: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal;
signal?: TelemetrytypesSignalDTO;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3871,17 +3873,6 @@ export type DashboardtypesDashboardSpecDTODatasources = {
export enum DashboardtypesPanelKindDTO {
Panel = 'Panel',
}
export interface DashboardtypesDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name: string;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
}
@@ -4430,36 +4421,42 @@ export interface DashboardtypesQuerySpecDTO {
* @type string
*/
name?: string;
plugin: DashboardtypesQueryPluginDTO;
plugin?: DashboardtypesQueryPluginDTO;
}
export interface DashboardtypesQueryDTO {
kind: Querybuildertypesv5RequestTypeDTO;
spec: DashboardtypesQuerySpecDTO;
kind?: Querybuildertypesv5RequestTypeDTO;
spec?: DashboardtypesQuerySpecDTO;
}
export interface DashboardtypesPanelSpecDTO {
display: DashboardtypesDisplayDTO;
display?: DashboardPanelDisplayDTO;
/**
* @type array
*/
links?: DashboardLinkDTO[];
plugin: DashboardtypesPanelPluginDTO;
plugin?: DashboardtypesPanelPluginDTO;
/**
* @type array,null
* @type array
*/
queries: DashboardtypesQueryDTO[] | null;
queries?: DashboardtypesQueryDTO[];
}
export interface DashboardtypesPanelDTO {
kind: DashboardtypesPanelKindDTO;
spec: DashboardtypesPanelSpecDTO;
kind?: DashboardtypesPanelKindDTO;
spec?: DashboardtypesPanelSpecDTO;
}
export type DashboardtypesDashboardSpecDTOPanels = {
export type DashboardtypesDashboardSpecDTOPanelsAnyOf = {
[key: string]: DashboardtypesPanelDTO;
};
/**
* @nullable
*/
export type DashboardtypesDashboardSpecDTOPanels =
DashboardtypesDashboardSpecDTOPanelsAnyOf | null;
export enum DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind {
Grid = 'Grid',
}
@@ -4556,7 +4553,7 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display: DashboardtypesDisplayDTO;
display?: VariableDisplayDTO;
/**
* @type string
*/
@@ -4598,23 +4595,23 @@ export interface DashboardtypesDashboardSpecDTO {
* @type object
*/
datasources?: DashboardtypesDashboardSpecDTODatasources;
display: DashboardtypesDisplayDTO;
display?: CommonDisplayDTO;
/**
* @type string
*/
duration?: string;
/**
* @type array
* @type array,null
*/
layouts: DashboardtypesLayoutDTO[];
layouts?: DashboardtypesLayoutDTO[] | null;
/**
* @type array
*/
links?: DashboardLinkDTO[];
/**
* @type object
* @type object,null
*/
panels: DashboardtypesDashboardSpecDTOPanels;
panels?: DashboardtypesDashboardSpecDTOPanels;
/**
* @type string
*/
@@ -4622,13 +4619,13 @@ export interface DashboardtypesDashboardSpecDTO {
/**
* @type array
*/
variables: DashboardtypesVariableDTO[];
variables?: DashboardtypesVariableDTO[];
}
export enum DashboardtypesDatasourcePluginKindDTO {
'signoz/Datasource' = 'signoz/Datasource',
}
export interface TagtypesGettableTagDTO {
export interface TagtypesPostableTagDTO {
/**
* @type string
*/
@@ -4678,7 +4675,7 @@ export interface DashboardtypesGettableDashboardV2DTO {
/**
* @type array,null
*/
tags: TagtypesGettableTagDTO[] | null;
tags: TagtypesPostableTagDTO[] | null;
/**
* @type string
* @format date-time
@@ -4736,157 +4733,6 @@ export interface DashboardtypesJSONPatchOperationDTO {
value?: unknown;
}
export enum DashboardtypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum DashboardtypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface DashboardtypesListedDashboardV2SpecDTO {
display?: DashboardtypesDisplayDTO;
}
export interface DashboardtypesListedDashboardForUserV2DTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type boolean
*/
pinned: boolean;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesListedDashboardV2SpecDTO;
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesListableDashboardForUserV2DTO {
/**
* @type array
*/
dashboards: DashboardtypesListedDashboardForUserV2DTO[];
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export interface DashboardtypesListedDashboardV2DTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesListedDashboardV2SpecDTO;
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesListableDashboardV2DTO {
/**
* @type array
*/
dashboards: DashboardtypesListedDashboardV2DTO[];
/**
* @type array
*/
tags: TagtypesGettableTagDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4903,17 +4749,6 @@ export type DashboardtypesPatchableDashboardV2DTO =
| DashboardtypesJSONPatchOperationDTO[]
| null;
export interface TagtypesPostableTagDTO {
/**
* @type string
*/
key: string;
/**
* @type string
*/
value: string;
}
export interface DashboardtypesPostableDashboardV2DTO {
/**
* @type boolean
@@ -9736,19 +9571,6 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type GetStats200Data = { [key: string]: unknown };
export type GetStats200 = {
/**
* @type object
*/
data: GetStats200Data;
/**
* @type string
*/
status: string;
};
export type GetTraceAggregationsPathParameters = {
traceID: string;
};
@@ -9829,40 +9651,6 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardsV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
sort?: DashboardtypesListSortDTO;
/**
* @description undefined
*/
order?: DashboardtypesListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsV2200 = {
data: DashboardtypesListableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardV2201 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
@@ -9871,9 +9659,6 @@ export type CreateDashboardV2201 = {
status: string;
};
export type DeleteDashboardV2PathParameters = {
id: string;
};
export type GetDashboardV2PathParameters = {
id: string;
};
@@ -10706,46 +10491,6 @@ export type GetMyUser200 = {
status: string;
};
export type ListDashboardsForUserV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
sort?: DashboardtypesListSortDTO;
/**
* @description undefined
*/
order?: DashboardtypesListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsForUserV2200 = {
data: DashboardtypesListableDashboardForUserV2DTO;
/**
* @type string
*/
status: string;
};
export type UnpinDashboardV2PathParameters = {
id: string;
};
export type PinDashboardV2PathParameters = {
id: string;
};
export type GetHosts200 = {
data: ZeustypesGettableHostDTO;
/**

View File

@@ -1,96 +0,0 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useQuery } from 'react-query';
import type {
InvalidateOptions,
QueryClient,
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type { GetStats200, RenderErrorResponseDTO } from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
/**
* This endpoint returns the collected stats for the organization
* @summary Get stats
*/
export const getStats = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetStats200>({
url: `/api/v1/stats`,
method: 'GET',
signal,
});
};
export const getGetStatsQueryKey = () => {
return [`/api/v1/stats`] as const;
};
export const getGetStatsQueryOptions = <
TData = Awaited<ReturnType<typeof getStats>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getStats>>, TError, TData>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetStatsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getStats>>> = ({
signal,
}) => getStats(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getStats>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetStatsQueryResult = NonNullable<
Awaited<ReturnType<typeof getStats>>
>;
export type GetStatsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get stats
*/
export function useGetStats<
TData = Awaited<ReturnType<typeof getStats>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getStats>>, TError, TData>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetStatsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get stats
*/
export const invalidateGetStats = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetStatsQueryKey() },
options,
);
return queryClient;
};

View File

@@ -1,11 +1,5 @@
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
$dropdown-base-height: 250px;
$recents-header-height: 30px;
$recent-row-height: 36px;
// how many recents are rendered, this caps how tall the dropdown can grow to fit them.
$max-recents-shown: 5;
.code-mirror-where-clause {
width: 100%;
display: flex;
@@ -123,23 +117,7 @@ $max-recents-shown: 5;
width: 100% !important;
max-width: 100% !important;
font-family: 'Space Mono', monospace !important;
max-height: $dropdown-base-height !important;
overflow-y: auto !important;
// Recents render at the top of the dropdown ahead of regular suggestions.
// At the base max-height, having recents in view would crowd out the
// suggestion list below. This loop grows the dropdown by one row's worth
// of height for every recent present (up to $max-recents-shown), plus the
// section header. `:has(> li:nth-of-type(N) .cm-completionIcon-recent)`
// matches when the Nth child of <ul> is a recent — i.e. there are at
// least N recents visible.
@for $i from 1 through $max-recents-shown {
&:has(> li:nth-of-type(#{$i}) .cm-completionIcon-recent) {
max-height: $dropdown-base-height +
$recents-header-height +
($i * $recent-row-height) !important;
}
}
min-height: 200px !important;
&::-webkit-scrollbar {
width: 0.3rem;
@@ -155,19 +133,6 @@ $max-recents-shown: 5;
background: transparent;
}
completion-section {
display: block;
padding: 10px 12px 6px;
font-size: 10px !important;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--l3-foreground, var(--l2-foreground));
opacity: 0.7;
border-bottom: 0;
background-color: transparent;
}
li {
width: 100% !important;
max-width: 100% !important;
@@ -194,78 +159,11 @@ $max-recents-shown: 5;
display: none !important;
}
.cm-completionDetail {
margin-left: auto;
font-style: normal;
font-size: var(--periscope-font-size-small, 11px);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.55;
}
&[aria-selected='true'] {
background: var(--l3-background) !important;
font-weight: 600 !important;
}
}
li:has(.cm-completionIcon-recent) {
&:hover .cm-recent-delete,
&[aria-selected='true'] .cm-recent-delete {
opacity: 1;
}
}
li .cm-completionLabel {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-recent-delete {
margin-left: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--l2-foreground);
font-size: 18px;
line-height: 1;
cursor: pointer;
opacity: 0.5;
transition:
opacity 0.12s ease,
background-color 0.12s ease;
&:hover {
opacity: 1;
background: color-mix(in srgb, var(--l2-foreground) 18%, transparent);
}
}
}
&::after {
content: '↓↑ to navigate · ↵ to apply · esc to dismiss';
display: block;
padding: 8px 12px;
border-top: 1px solid var(--l1-border);
font-family: 'Space Mono', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--l2-foreground);
opacity: 0.6;
background: color-mix(in srgb, var(--l1-background) 50%, transparent);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
}

View File

@@ -46,15 +46,8 @@ import {
import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
import type { SignalType } from 'types/api/v5/queryRange';
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
import {
combineInitialAndUserExpression,
getRecentOptions,
renderRecentDeleteButton,
} from './utils';
import { queryExamples } from './constants';
import { combineInitialAndUserExpression } from './utils';
import './QuerySearch.styles.scss';
@@ -1257,41 +1250,6 @@ function QuerySearch({
};
}
const signal = dataSource as SignalType;
function combinedSuggestions(
context: CompletionContext,
): CompletionResult | null {
const fullDoc = context.state.doc.toString();
const recentOptions = getRecentOptions(
getRecentQueries(signal, signalSource ?? ''),
fullDoc,
);
const result = autoSuggestions(context);
const suggestionOptions = (result?.options || []).map((opt) => ({
...opt,
section: SUGGESTIONS_SECTION,
}));
if (recentOptions.length === 0 && suggestionOptions.length === 0) {
return result;
}
if (!result) {
return {
from: 0,
to: fullDoc.length,
options: recentOptions,
};
}
return {
...result,
options: [...recentOptions, ...suggestionOptions],
};
}
// Effect to handle focus state and trigger suggestions
useEffect(() => {
const clearTimeout = toggleSuggestions(10);
@@ -1440,12 +1398,11 @@ function QuerySearch({
})}
extensions={[
autocompletion({
override: [combinedSuggestions],
override: [autoSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,

View File

@@ -1,14 +1,3 @@
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
// Custom CodeMirror Completion.type for recent-query entries. Used to discriminate
// recents from regular autocomplete completions in renderers and event handlers.
export const RECENT_COMPLETION_TYPE = 'recent';
// Max number of recents rendered in the autocomplete dropdown.
// Do change $max-recents-shown: in QuerySearch.styles.scss if you change this.
export const RECENTS_DISPLAY_CAP = 5;
export const queryExamples = [
{
label: 'Basic Query',

View File

@@ -1,19 +1,3 @@
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
import type { Completion } from '@codemirror/autocomplete';
import type { EditorView } from '@uiw/react-codemirror';
import dayjs from 'dayjs';
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
import type { RecentQueryEntry } from 'lib/recentQueries/types';
import type { SignalType } from 'types/api/v5/queryRange';
import 'utils/timeUtils';
import {
RECENT_COMPLETION_TYPE,
RECENTS_DISPLAY_CAP,
RECENTS_SECTION,
} from './constants';
export function combineInitialAndUserExpression(
initial: string,
user: string,
@@ -54,106 +38,3 @@ export function getUserExpressionFromCombined(
}
return c;
}
// Filters and projects a list of recent-query entries into CodeMirror completions.
// Entries are supplied by the caller (typically via the useRecents hook) so this
// function stays pure and React doesn't have to re-subscribe inside CodeMirror's
// autocomplete callback.
export function getRecentOptions(
entries: RecentQueryEntry[],
fullDoc: string,
): Completion[] {
const normalizedDoc = normalizeFilterExpression(fullDoc);
const matches = entries
.filter((e) => {
const normalizedRecent = normalizeFilterExpression(e.filter.expression);
if (normalizedRecent === normalizedDoc) {
return false;
}
if (normalizedDoc === '') {
return true;
}
return normalizedRecent.includes(normalizedDoc);
})
.slice(0, RECENTS_DISPLAY_CAP);
return matches.map((entry, index) => ({
label: entry.filter.expression,
type: RECENT_COMPLETION_TYPE,
// CodeMirror sorts within a section by boost desc, then label asc. The store
// returns entries newest-first, so we mirror that by giving the newest entry
// the highest boost — otherwise CM falls back to alphabetical order and the
// "most recently used" expectation breaks. Stays within the recents section
// because section.rank keeps recents above suggestions regardless of boost.
boost: matches.length - index,
section: RECENTS_SECTION,
detail: dayjs(entry.lastUsedAt).fromNow(),
recentId: entry.id,
recentSignal: entry.signal,
recentSource: entry.source,
apply: (view: EditorView): void => {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: entry.filter.expression,
},
selection: { anchor: entry.filter.expression.length },
});
closeCompletion(view);
},
}));
}
export function renderRecentDeleteButton(
completion: Completion,
_state: unknown,
view: EditorView | null,
): Node | null {
if (completion.type !== RECENT_COMPLETION_TYPE) {
return null;
}
const c = completion as Completion & {
recentId?: string;
recentSignal?: SignalType;
recentSource?: string;
};
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'cm-recent-delete';
btn.setAttribute('aria-label', 'Remove from recent searches');
btn.title = 'Remove from recent searches';
btn.textContent = '×';
queueMicrotask(() => {
if (btn.parentElement) {
btn.parentElement.title = completion.label;
}
});
const stop = (e: Event): void => {
e.preventDefault();
e.stopPropagation();
};
// CodeMirror's autocomplete closes the popup on pointerdown / mousedown outside
// the editor. The delete button lives inside the popup, so we must stop those
// events early — otherwise clicking × would dismiss the dropdown before the
// click handler fires and the entry wouldn't actually get removed.
btn.addEventListener('pointerdown', stop);
btn.addEventListener('mousedown', stop);
btn.addEventListener('click', (e) => {
stop(e);
if (!c.recentId || !c.recentSignal) {
return;
}
recentQueriesStore.remove(c.recentId, c.recentSignal, c.recentSource ?? '');
if (view) {
view.focus();
startCompletion(view);
}
});
return btn;
}

View File

@@ -36,7 +36,6 @@ export const REACT_QUERY_KEY = {
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_TRACE_V3_FLAMEGRAPH: 'GET_TRACE_V3_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',
GET_DEPLOYMENT_LIST: 'GET_DEPLOYMENT_LIST',

View File

@@ -91,7 +91,6 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
} as const;
export default ROUTES;

View File

@@ -40,31 +40,13 @@ type SpeechRecognitionConstructor = new () => ISpeechRecognition;
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
// Some hardened/enterprise browsers install a getter
// on window.SpeechRecognition that THROWS on access ("Web Speech API is disabled
// due to your security policy") instead of leaving the property undefined.
// Because this resolves at module-evaluation time, an uncaught throw here aborts
// the entire bundle and the app renders a blank page. Read defensively so a
// throwing getter degrades to "unsupported" rather than crashing the app.
function resolveSpeechRecognitionAPI(): SpeechRecognitionConstructor | null {
if (typeof window === 'undefined') {
return null;
}
try {
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).SpeechRecognition ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).webkitSpeechRecognition ??
null
);
} catch {
return null;
}
}
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
resolveSpeechRecognitionAPI();
typeof window !== 'undefined'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((window as any).SpeechRecognition ??
(window as any).webkitSpeechRecognition ??
null)
: null;
export type SpeechRecognitionError =
| 'not-supported'

View File

@@ -1,7 +1,8 @@
.billingContainer {
margin-bottom: var(--spacing-20);
padding-top: 36px;
width: 90%;
margin: 0 auto var(--spacing-20);
margin: 0 auto;
.pageHeader {
margin-bottom: var(--spacing-8);

View File

@@ -1,6 +1,6 @@
.license-key-callout {
margin: var(--spacing-4) var(--spacing-6);
width: auto !important;
width: auto;
.license-key-callout__description {
display: flex;

View File

@@ -1,41 +0,0 @@
import { useQueries } from 'react-query';
import { render, screen } from 'tests/test-utils';
import GeneralSettings from '../index';
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: jest.fn(),
}));
const baseQueryResult = {
isError: false,
isLoading: false,
isFetching: false,
isSuccess: true,
data: undefined,
error: null,
refetch: jest.fn(),
};
describe('GeneralSettings index', () => {
it('renders fallback message when logs query fails with a non-APIError', () => {
(useQueries as jest.Mock).mockReturnValue([
{ ...baseQueryResult },
{ ...baseQueryResult },
{
...baseQueryResult,
isError: true,
isSuccess: false,
error: new TypeError(
"Cannot read properties of undefined (reading 'code')",
),
},
{ ...baseQueryResult },
]);
render(<GeneralSettings />);
expect(screen.getByText('something_went_wrong')).toBeInTheDocument();
});
});

View File

@@ -76,9 +76,7 @@ function GeneralSettings(): JSX.Element {
if (getRetentionPeriodLogsApiResponse.isError || getDisksResponse.isError) {
return (
<Typography>
{(getRetentionPeriodLogsApiResponse.error instanceof APIError
? getRetentionPeriodLogsApiResponse.error.getErrorMessage()
: undefined) ||
{(getRetentionPeriodLogsApiResponse.error as APIError).getErrorMessage() ||
getDisksResponse.data?.error ||
t('something_went_wrong')}
</Typography>

View File

@@ -796,7 +796,7 @@ export const getClusterMetricsQueryPayload = (
key: k8sDeploymentDesiredKey,
type: 'Gauge',
},
aggregateOperator: 'latest',
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
@@ -839,7 +839,7 @@ export const getClusterMetricsQueryPayload = (
reduceTo: ReduceOperators.LAST,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'latest',
timeAggregation: 'avg',
},
],
queryFormulas: [],

View File

@@ -40,7 +40,6 @@ import { K8S_ENTITY_EVENTS_EXPRESSION_KEY, useEntityEvents } from './hooks';
import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
import styles from './EntityEvents.module.scss';
import { useTimezone } from 'providers/Timezone';
interface EventDataType {
key: string;
@@ -168,25 +167,17 @@ function EntityEventsContent({
[events],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns: TableColumnsType<EventDataType> = useMemo(
() => [
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
{
title: 'Timestamp',
dataIndex: 'timestamp',
width: 240,
ellipsis: true,
key: 'timestamp',
render: (value: string | number): string =>
formatTimezoneAdjustedTimestamp(
typeof value === 'string' ? value : value / 1e6,
),
},
{ title: 'Body', dataIndex: 'body', key: 'body' },
],
[formatTimezoneAdjustedTimestamp],
);
const columns: TableColumnsType<EventDataType> = [
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
{
title: 'Timestamp',
dataIndex: 'timestamp',
width: 240,
ellipsis: true,
key: 'timestamp',
},
{ title: 'Body', dataIndex: 'body', key: 'body' },
];
const handleExpandRowIcon = ({
expanded,

View File

@@ -41,7 +41,6 @@ import { getTraceListColumns } from './traceListColumns';
import { getEntityTracesQueryPayload } from './utils';
import styles from './EntityTraces.module.scss';
import { useTimezone } from 'providers/Timezone';
interface Props {
timeRange: {
@@ -137,11 +136,7 @@ function EntityTracesContent({
[timeRange.startTime, timeRange.endTime, userExpression],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const traceListColumns = getTraceListColumns(
selectedEntityTracesColumns,
formatTimezoneAdjustedTimestamp,
);
const traceListColumns = getTraceListColumns(selectedEntityTracesColumns);
const isKeyNotFound = isKeyNotFoundError(error);
const isDataEmpty =

View File

@@ -1,14 +1,15 @@
import { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import {
BlockLink,
getTraceLink,
} from 'container/TracesExplorer/ListView/utils';
import dayjs from 'dayjs';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FormatTimezoneAdjustedTimestamp } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
const keyToLabelMap: Record<string, string> = {
timestamp: 'Timestamp',
@@ -58,7 +59,6 @@ const getValueForKey = (data: Record<string, any>, key: string): any => {
export const getTraceListColumns = (
selectedColumns: BaseAutocompleteData[],
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp,
): ColumnsType<RowData> => {
const columns: ColumnsType<RowData> =
selectedColumns.map(({ dataType, key, type }) => ({
@@ -73,8 +73,8 @@ export const getTraceListColumns = (
if (primaryKey === 'timestamp') {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(value)
: formatTimezoneAdjustedTimestamp(value / 1e6);
? dayjs(value).format(DATE_TIME_FORMATS.ISO_DATETIME_MS)
: dayjs(value / 1e6).format(DATE_TIME_FORMATS.ISO_DATETIME_MS);
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>

View File

@@ -1366,7 +1366,7 @@ export const getPodMetricsQueryPayload = (
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
spaceAggregation: 'avg',
stepInterval: 60,
timeAggregation: 'avg',
},

View File

@@ -86,9 +86,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'capacity',
header: 'Capacity',
header: 'Volume Capacity',
accessorFn: (row): number => row.volumeCapacity,
width: { min: 140 },
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const capacity = value as number;
@@ -105,9 +105,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'usage',
header: 'Used',
header: 'Volume Utilization',
accessorFn: (row): number => row.volumeUsage,
width: { min: 140 },
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const usage = value as number;
@@ -124,9 +124,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'available',
header: 'Available',
header: 'Volume Available',
accessorFn: (row): number => row.volumeAvailable,
width: { min: 140 },
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const available = value as number;
@@ -141,61 +141,4 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
);
},
},
{
id: 'inodes',
header: 'Inodes',
accessorFn: (row): number => row.volumeInodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodes = value as number;
return (
<ValidateColumnValueWrapper
value={inodes}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes metric"
>
<TanStackTable.Text>{inodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'inodesUsed',
header: 'Inodes Used',
accessorFn: (row): number => row.volumeInodesUsed,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodesUsed = value as number;
return (
<ValidateColumnValueWrapper
value={inodesUsed}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes used metric"
>
<TanStackTable.Text>{inodesUsed}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'inodesFree',
header: 'Inodes Free',
accessorFn: (row): number => row.volumeInodesFree,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodesFree = value as number;
return (
<ValidateColumnValueWrapper
value={inodesFree}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes free metric"
>
<TanStackTable.Text>{inodesFree}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -1,148 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Plus, Trash2 } from '@signozhq/icons';
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import { CACHE_BUCKETS, CACHE_MODE_OPTIONS } from './constants';
import { parsePricingAmount } from './utils';
import type { CacheBucketKey, DrawerDraft } from './types';
type Pricing = DrawerDraft['pricing'];
interface ExtraPricingBucketsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
// Optional, add-on-demand pricing buckets. A bucket is "added" once its value
// is non-null; adding seeds it at 0 and removing clears it back to null. Only
// the cache buckets are backed by the API today (pricing.cache.read/write).
function ExtraPricingBuckets({
pricing,
isReadOnly,
onChange,
}: ExtraPricingBucketsProps): JSX.Element {
const [isPicking, setIsPicking] = useState<boolean>(false);
const addedBuckets = CACHE_BUCKETS.filter((b) => pricing[b.key] !== null);
const availableBuckets = CACHE_BUCKETS.filter((b) => pricing[b.key] === null);
const addBucket = (key: CacheBucketKey): void => {
onChange({ [key]: 0 } as Partial<Pricing>);
// Close the picker once nothing is left to add.
if (availableBuckets.length <= 1) {
setIsPicking(false);
}
};
const removeBucket = (key: CacheBucketKey): void => {
onChange({ [key]: null } as Partial<Pricing>);
};
return (
<div className="extra-buckets-section drawer-section">
<div className="extra-buckets-section__head">
<span className="field-label">Extra pricing buckets</span>
<span className="optional-label">optional</span>
</div>
{addedBuckets.map((bucket) => (
<div className="bucket-row" key={bucket.key}>
<span className="bucket-row__name">{bucket.label}</span>
<Input
type="number"
min={0}
step={0.01}
value={pricing[bucket.key] ?? 0}
placeholder="0.00"
disabled={isReadOnly}
onChange={(e): void =>
// Empty coerces to 0 (not null) so editing never makes the row
// vanish — removal is explicit via the trash button.
onChange({
[bucket.key]: parsePricingAmount(e.target.value) ?? 0,
} as Partial<Pricing>)
}
testId={`drawer-${bucket.testId}-cost`}
/>
<span className="bucket-row__unit">/ 1M</span>
{!isReadOnly && (
<button
type="button"
className="bucket-row__remove"
onClick={(): void => removeBucket(bucket.key)}
aria-label={`Remove ${bucket.label}`}
data-testid={`drawer-remove-${bucket.testId}`}
>
<Trash2 size={14} />
</button>
)}
</div>
))}
{addedBuckets.length > 0 && (
<div className="pricing-field cache-mode-field">
<label htmlFor="cache-mode">Cache mode</label>
<SelectSimple
id="cache-mode"
value={pricing.cacheMode}
items={CACHE_MODE_OPTIONS}
onChange={(v): void => onChange({ cacheMode: v as CacheModeDTO })}
disabled={isReadOnly}
className="full-width"
withPortal={false}
testId="drawer-cache-mode"
/>
</div>
)}
{!isReadOnly && !isPicking && availableBuckets.length > 0 && (
<Button
variant="dashed"
color="secondary"
className="bucket-add-btn"
prefix={<Plus size={14} />}
onClick={(): void => setIsPicking(true)}
testId="drawer-add-bucket-btn"
>
Add pricing bucket
</Button>
)}
{!isReadOnly && isPicking && (
<div className="bucket-picker" data-testid="drawer-bucket-picker">
<div className="bucket-picker__title">Add a pricing bucket</div>
<div className="bucket-picker__chips">
{availableBuckets.map((bucket) => (
<Button
key={bucket.key}
variant="outlined"
color="secondary"
size="sm"
prefix={<Plus size={12} />}
onClick={(): void => addBucket(bucket.key)}
testId={`drawer-add-bucket-${bucket.testId}`}
>
{bucket.label}
</Button>
))}
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={(): void => setIsPicking(false)}
testId="drawer-add-bucket-cancel"
>
Cancel
</Button>
</div>
)}
</div>
);
}
export default ExtraPricingBuckets;

View File

@@ -1,172 +0,0 @@
.llm-observability-model-pricing {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 32px;
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
&__title {
h1 {
margin: 0;
font-size: 22px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 13px;
}
}
&__actions {
display: flex;
gap: 8px;
}
}
.page-tabs {
.ant-tabs-nav {
margin-bottom: 0;
}
}
.filters-bar {
display: flex;
gap: 12px;
align-items: center;
&__search {
flex: 1;
max-width: 360px;
}
&__source,
&__currency {
min-width: 160px;
}
&__add {
margin-left: auto;
}
}
.page-error {
padding: 12px 16px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 13px;
}
.page-pagination {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.page-footer {
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
.model-costs-table {
.ant-table-thead > tr > th {
color: var(--bg-vanilla-400) !important;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ant-table-tbody > tr > td {
color: var(--bg-vanilla-400);
}
.model-cell {
display: flex;
flex-direction: column;
gap: 2px;
// Allow the flex children to shrink below their content width so the
// table's fixed-layout / nowrap cells truncate instead of overflowing
// into the Provider column.
min-width: 0;
&__name {
color: var(--bg-vanilla-100);
font-weight: 600;
}
&__name,
&__canonical-id {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__canonical-id {
color: var(--bg-vanilla-400);
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
}
.price-cell {
font-family: var(--code-font-family, monospace);
color: var(--bg-vanilla-400);
}
.extra-buckets {
display: flex;
flex-wrap: wrap;
gap: 6px;
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
}
&__key {
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
&__price {
font-family: var(--code-font-family, monospace);
font-weight: 600;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.source-badge {
margin: 0;
&--auto {
background: rgba(78, 116, 248, 0.12);
color: var(--bg-robin-400);
border-color: rgba(78, 116, 248, 0.24);
}
&--override {
background: rgba(245, 175, 25, 0.12);
color: var(--bg-amber-400);
border-color: rgba(245, 175, 25, 0.24);
}
}
&__row--selected {
background: rgba(78, 116, 248, 0.06);
}
}

View File

@@ -1,185 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Pagination } from '@signozhq/ui/pagination';
import { SelectSimple } from '@signozhq/ui/select';
import { Tabs } from '@signozhq/ui/tabs';
import { Plus, Search } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebounce from 'hooks/useDebounce';
import { useAppContext } from 'providers/App/App';
import ModelCostDrawer from './ModelCostDrawer';
import ModelCostsTable from './ModelCostsTable';
import { useModelCostDrawer } from './useModelCostDrawer';
import { useModelPricingFilters } from './useModelPricingFilters';
import type {
ListModelPricingParams,
PricingRule,
SourceFilter,
} from './types';
import './LLMObservabilityModelPricing.styles.scss';
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: 'all', label: 'Source: All' },
{ value: 'auto', label: 'Auto-populated' },
{ value: 'override', label: 'User override' },
];
const CURRENCY_OPTIONS = [
{ value: 'USD', label: 'USD' },
{ value: 'EUR', label: 'EUR', disabled: true },
{ value: 'INR', label: 'INR', disabled: true },
];
const PAGE_SIZE = 20;
function LLMObservabilityModelPricing(): JSX.Element {
const { search, source, page, setSearch, setSource, setPage } =
useModelPricingFilters();
const [currency, setCurrency] = useState<string>('USD');
// Controlled locally for instant typing feedback; the URL `q` param (which
// drives the request) is updated on a debounce so we don't fire a request
// per keystroke.
const [searchInput, setSearchInput] = useState<string>(search);
const debouncedSearch = useDebounce(searchInput, 400);
useEffect(() => {
if (debouncedSearch.trim() !== search) {
setSearch(debouncedSearch);
}
}, [debouncedSearch, search, setSearch]);
const listParams: ListModelPricingParams = {
offset: (page - 1) * PAGE_SIZE,
limit: PAGE_SIZE,
...(search ? { q: search } : {}),
...(source !== 'all' ? { source } : {}),
};
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
);
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
const total = data?.data?.total ?? 0;
const drawer = useModelCostDrawer();
return (
<div
className="llm-observability-model-pricing"
data-testid="llm-observability-model-pricing-page"
>
<header className="page-header">
<div className="page-header__title">
<h1>Configuration</h1>
<p>Model pricing and cost estimation settings</p>
</div>
</header>
<Tabs
className="page-tabs"
defaultValue="model-costs"
items={[
{ key: 'model-costs', label: 'Model costs', children: null },
{
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>
<div className="filters-bar">
<Input
className="filters-bar__search"
placeholder="Search by model or provider…"
prefix={<Search size={14} />}
value={searchInput}
onChange={(event): void => setSearchInput(event.target.value)}
testId="search-input"
/>
<SelectSimple
className="filters-bar__source"
value={source}
onChange={(value): void => setSource(value as SourceFilter)}
items={SOURCE_OPTIONS}
testId="source-select"
/>
<SelectSimple
className="filters-bar__currency"
value={currency}
onChange={(value): void => setCurrency(value as string)}
items={CURRENCY_OPTIONS}
testId="currency-select"
/>
{canManagePricing && (
<Button
variant="solid"
color="primary"
className="filters-bar__add"
prefix={<Plus size={14} />}
onClick={(): void => drawer.openForAdd()}
testId="add-model-cost-btn"
>
Add model cost
</Button>
)}
</div>
{isError && (
<div className="page-error" role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
<ModelCostsTable
rules={rules}
isLoading={isLoading}
selectedRuleId={drawer.selectedRuleId}
canManage={canManagePricing}
onEdit={drawer.openForEdit}
/>
{total > PAGE_SIZE && (
<Pagination
className="page-pagination"
total={total}
pageSize={PAGE_SIZE}
current={page}
onPageChange={setPage}
/>
)}
<footer className="page-footer">
Showing {rules.length} of {total} model{total === 1 ? '' : 's'}
{' · '}All prices per 1M tokens (USD)
</footer>
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={drawer.save}
onDelete={drawer.deleteRule}
isSaving={drawer.isSaving}
isDeleting={drawer.isDeleting}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
</div>
);
}
export default LLMObservabilityModelPricing;

View File

@@ -1,388 +0,0 @@
.model-cost-drawer {
// Uniform horizontal padding across header / body / footer. The header and
// footer read these dialog vars; the body (rendered in drawer-description)
// is set directly below.
--dialog-header-padding: 20px 24px;
--dialog-footer-padding: 16px 24px;
// The drawer body — children render inside [data-slot='drawer-description']
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
[data-slot='drawer-description'] {
display: flex;
flex-direction: column;
gap: 18px;
padding: 20px 24px;
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
display: flex;
overflow-y: auto;
&__title {
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
// Horizontal padding is provided by the drawer-footer slot var above.
padding: 0;
width: 100%;
&-right {
display: flex;
gap: 8px;
margin-left: auto;
}
}
.drawer-section {
display: flex;
flex-direction: column;
gap: 6px;
label,
.field-label {
font-size: 12px;
font-weight: 600;
color: var(--bg-vanilla-200);
}
.help {
margin: 0;
font-size: 11px;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.required {
color: var(--bg-cherry-400);
}
.full-width {
width: 100%;
}
.drawer-surface {
padding: 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-300);
}
}
}
.managed-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--bg-vanilla-400);
}
.pattern-box {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
}
.pattern-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
}
.pattern-chip {
display: inline-flex;
align-items: center;
gap: 4px;
&__remove {
background: transparent;
border: none;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
&:hover {
color: var(--bg-cherry-400);
}
}
}
.pattern-add {
display: flex;
gap: 6px;
}
.help {
code {
padding: 1px 4px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
font-family: var(--code-font-family, monospace);
font-size: 10px;
}
}
.source-radio-group {
// @signozhq/ui's RadioGroupItem defaults its unchecked border to
// --l3-background, which matches the drawer surface and makes the dot
// invisible. Override with a contrasting border so users can see the
// unchecked state.
--radio-group-item-border-color: var(--bg-slate-200);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
// Layout overrides for @signozhq/ui's RadioGroupItem wrapper. The
// library injects single-class CSS at runtime (after our bundled
// stylesheet loads), so we use a two-class selector to win the
// cascade and force the wrapper to lay the dot on the left with the
// label text flush beside it.
.source-radio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
margin: 0;
width: 100%;
// Include padding + border in the 100% width so the card fits inside
// the SOURCE surface instead of overflowing its right edge.
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
// The radio button itself: keep it fixed-size and aligned with
// the title baseline (margin-top compensates for align-items:
// flex-start vs the title's line-box).
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
// The library wraps children in a <label>. Make it grow into the
// remaining width and reset the .drawer-section label typography
// leak (set earlier in this file) so the title/desc divs use
// their own styles.
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
&__title {
font-weight: 600;
font-size: 13px;
color: var(--bg-vanilla-100);
}
&__desc {
margin-top: 2px;
font-size: 12px;
color: var(--bg-vanilla-400);
}
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
// Use :has() to highlight the wrapper card when its inner button is checked.
&.source-radio--auto:has(button[data-state='checked']) {
background: rgba(78, 116, 248, 0.1);
border-color: rgba(78, 116, 248, 0.3);
}
&.source-radio--override:has(button[data-state='checked']) {
background: rgba(245, 175, 25, 0.1);
border-color: rgba(245, 175, 25, 0.3);
}
&:hover {
background: rgba(255, 255, 255, 0.04);
}
}
}
.reset-confirm {
margin-top: 12px;
padding: 12px;
border-radius: 4px;
background: rgba(78, 116, 248, 0.06);
border: 1px solid rgba(78, 116, 248, 0.2);
p {
margin: 0 0 10px;
font-size: 12px;
}
&__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
.pricing-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.pricing-field {
display: flex;
flex-direction: column;
gap: 4px;
input {
width: 100%;
}
}
.cache-mode-field {
margin-top: 10px;
}
.extra-buckets-section {
margin-top: 14px;
gap: 10px;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
.optional-label {
font-size: 11px;
font-weight: 400;
color: var(--bg-vanilla-400);
}
}
}
.bucket-row {
display: flex;
align-items: center;
gap: 10px;
&__name {
flex: 0 0 110px;
font-size: 12px;
color: var(--bg-vanilla-200);
font-family: var(--code-font-family, monospace);
}
input {
flex: 1 1 auto;
min-width: 0;
}
&__unit {
flex: 0 0 auto;
font-size: 12px;
color: var(--bg-vanilla-400);
}
&__remove {
flex: 0 0 auto;
background: transparent;
border: none;
padding: 4px;
cursor: pointer;
color: var(--bg-vanilla-400);
display: inline-flex;
align-items: center;
&:hover {
color: var(--bg-cherry-400);
}
}
}
.bucket-add-btn {
width: 100%;
}
.bucket-picker {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
padding: 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
&__title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400);
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.drawer-error {
padding: 10px 12px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 12px;
}
}

View File

@@ -1,177 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Trash2 } from '@signozhq/icons';
import PatternEditor from './PatternEditor';
import PricingFields from './PricingFields';
import SourceSelector from './SourceSelector';
import { PROVIDER_OPTIONS } from './constants';
import { validateDraft } from './utils';
import type { DrawerDraft, DrawerMode } from './types';
import './ModelCostDrawer.styles.scss';
interface ModelCostDrawerProps {
isOpen: boolean;
mode: DrawerMode;
draft: DrawerDraft;
setDraft: (next: DrawerDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
canManage: boolean;
}
function ModelCostDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
canManage,
}: ModelCostDrawerProps): JSX.Element {
// Metadata (model id / provider / patterns / source) is editable by any
// manager. Pricing fields are editable only once the user picks "User
// override" — auto-populated pricing is managed by SigNoz. Write APIs are
// Admin-only, so non-managers can't edit anything.
const metadataReadOnly = !canManage;
const pricingReadOnly = !canManage || !draft.isOverride;
const validation = validateDraft(draft, mode);
const showValidationTooltip =
canManage && !validation.ok && !!validation.message;
const update = (patch: Partial<DrawerDraft>): void => {
setDraft({ ...draft, ...patch });
};
const footer = (
<div className="model-cost-drawer__footer">
{mode === 'edit' && canManage && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
loading={isDeleting}
testId="drawer-delete-btn"
>
Delete
</Button>
)}
<div className="model-cost-drawer__footer-right">
<Button
variant="outlined"
color="secondary"
onClick={onClose}
testId="drawer-cancel-btn"
>
{canManage ? 'Cancel' : 'Close'}
</Button>
{canManage && (
<TooltipSimple
title={showValidationTooltip ? validation.message : ''}
withPortal={false}
>
{/* span wrapper so the tooltip fires even when the button is disabled */}
<span className="model-cost-drawer__save-wrap">
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!validation.ok}
testId="drawer-save-btn"
>
Save
</Button>
</span>
</TooltipSimple>
)}
</div>
</div>
);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
width="base"
className="model-cost-drawer"
footer={footer}
title={mode === 'edit' ? 'Edit model cost' : 'Add model cost'}
subTitle="Pricing computes gen_ai.estimated_total_cost at ingest."
drawerHeaderProps={{ className: 'model-cost-drawer__title' }}
>
<div className="drawer-section">
<label htmlFor="billing-model-id">Billing model ID</label>
<Input
id="billing-model-id"
placeholder="e.g. openai:gpt-4o"
value={draft.modelName}
disabled={mode === 'edit' || metadataReadOnly}
onChange={(e): void => update({ modelName: e.target.value })}
testId="drawer-model-id-input"
/>
</div>
<div className="drawer-section">
<label htmlFor="provider-select">Provider</label>
<SelectSimple
id="provider-select"
value={draft.provider}
onChange={(value): void => update({ provider: value as string })}
items={PROVIDER_OPTIONS}
disabled={mode === 'edit' || metadataReadOnly}
className="full-width"
withPortal={false}
testId="drawer-provider-select"
/>
</div>
<PatternEditor
patterns={draft.patterns}
isReadOnly={metadataReadOnly}
onChange={(patterns): void => update({ patterns })}
/>
<SourceSelector
isOverride={draft.isOverride}
isReadOnly={metadataReadOnly}
disableAuto={mode === 'add'}
onChange={(isOverride): void => update({ isOverride })}
/>
<PricingFields
pricing={draft.pricing}
isReadOnly={pricingReadOnly}
onChange={(patch): void =>
setDraft({ ...draft, pricing: { ...draft.pricing, ...patch } })
}
/>
{saveError && (
<div className="drawer-error" role="alert">
{saveError}
</div>
)}
</DrawerWrapper>
);
}
export default ModelCostDrawer;

View File

@@ -1,179 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@signozhq/ui/table';
import { ChevronDown } from '@signozhq/icons';
import cx from 'classnames';
import { startCase } from 'lodash-es';
import type { PricingRule } from './types';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
} from './utils';
const COLUMN_COUNT = 8;
interface ModelCostsTableProps {
rules: PricingRule[];
isLoading: boolean;
selectedRuleId: string | null;
canManage: boolean;
onEdit: (rule: PricingRule) => void;
}
interface RowProps {
rule: PricingRule;
isSelected: boolean;
canManage: boolean;
onEdit: (rule: PricingRule) => void;
}
function ModelCostRow({
rule,
isSelected,
canManage,
onEdit,
}: RowProps): JSX.Element {
const buckets = getExtraBuckets(rule);
return (
<TableRow
className={cx({ 'model-costs-table__row--selected': isSelected })}
data-testid={`model-cost-row-${rule.id}`}
>
<TableCell>
<div className="model-cell">
<div
className="model-cell__name"
data-testid={`model-cell-name-${rule.id}`}
>
{rule.modelName}
</div>
<div
className="model-cell__canonical-id"
data-testid={`model-cell-canonical-id-${rule.id}`}
>
{getCanonicalId(rule)}
</div>
</div>
</TableCell>
<TableCell>{rule.provider}</TableCell>
<TableCell>
<span className="price-cell" data-testid={`price-cell-input-${rule.id}`}>
{formatPricePerMillion(rule.pricing?.input)}
</span>
</TableCell>
<TableCell>
<span className="price-cell" data-testid={`price-cell-output-${rule.id}`}>
{formatPricePerMillion(rule.pricing?.output)}
</span>
</TableCell>
<TableCell>
{buckets.length === 0 ? (
<span className="muted"></span>
) : (
<div className="extra-buckets">
{buckets.map((bucket) => (
<Badge
key={bucket.key}
color="vanilla"
variant="outline"
className="extra-buckets__chip"
>
<span className="extra-buckets__key">{startCase(bucket.key)}</span>
<span className="extra-buckets__price">
{formatPricePerMillion(bucket.pricePerMillion)}
</span>
</Badge>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge
color={rule.isOverride ? 'amber' : 'robin'}
variant="outline"
className="source-badge"
data-testid={`source-badge-${rule.id}`}
>
{getSourceLabel(rule)}
</Badge>
</TableCell>
<TableCell>{getRelativeLastSeen(rule)}</TableCell>
<TableCell>
<Button
variant="ghost"
color="secondary"
size="sm"
suffix={<ChevronDown size={14} />}
testId={`edit-rule-${rule.id}`}
onClick={(): void => onEdit(rule)}
>
{canManage ? 'Edit' : 'View'}
</Button>
</TableCell>
</TableRow>
);
}
function ModelCostsTable({
rules,
isLoading,
selectedRuleId,
canManage,
onEdit,
}: ModelCostsTableProps): JSX.Element {
return (
<Table className="model-costs-table" testId="model-costs-table">
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Input / 1M</TableHead>
<TableHead>Output / 1M</TableHead>
<TableHead>Extra buckets</TableHead>
<TableHead>Source</TableHead>
<TableHead>Last seen</TableHead>
<TableHead aria-label="Actions" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && rules.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="model-costs-table__empty">
Loading pricing rules
</TableCell>
</TableRow>
)}
{!isLoading && rules.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="model-costs-table__empty">
No model costs yet.
</TableCell>
</TableRow>
)}
{rules.map((rule) => (
<ModelCostRow
key={rule.id}
rule={rule}
isSelected={rule.id === selectedRuleId}
canManage={canManage}
onEdit={onEdit}
/>
))}
</TableBody>
</Table>
);
}
export default ModelCostsTable;

View File

@@ -1,96 +0,0 @@
import { useState } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { X } from '@signozhq/icons';
interface PatternEditorProps {
patterns: string[];
isReadOnly: boolean;
onChange: (patterns: string[]) => void;
}
// Model-name prefix patterns as removable chips + an add input.
function PatternEditor({
patterns,
isReadOnly,
onChange,
}: PatternEditorProps): JSX.Element {
const [patternInput, setPatternInput] = useState<string>('');
const addPattern = (): void => {
const next = patternInput.trim();
if (!next || patterns.includes(next)) {
setPatternInput('');
return;
}
onChange([...patterns, next]);
setPatternInput('');
};
const removePattern = (pattern: string): void => {
onChange(patterns.filter((p) => p !== pattern));
};
return (
<div className="drawer-section">
<span className="field-label">
Model name patterns <span className="muted">(prefix match)</span>
</span>
<div className="pattern-box">
<div className="pattern-chips">
{patterns.map((pattern) => (
<Badge
key={pattern}
color="vanilla"
variant="outline"
className="pattern-chip"
>
{pattern}*
{!isReadOnly && (
<button
type="button"
aria-label={`Remove pattern ${pattern}`}
className="pattern-chip__remove"
onClick={(): void => removePattern(pattern)}
>
<X size={10} />
</button>
)}
</Badge>
))}
</div>
{!isReadOnly && (
<div className="pattern-add">
<Input
placeholder="Add pattern…"
value={patternInput}
onChange={(e): void => setPatternInput(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
addPattern();
}
}}
testId="drawer-pattern-input"
/>
<Button
variant="outlined"
color="secondary"
onClick={addPattern}
testId="drawer-pattern-add-btn"
>
+ Add
</Button>
</div>
)}
</div>
<p className="muted help">
Each pattern uses <strong>prefix matching</strong> against{' '}
<code>gen_ai.request.model</code>.
</p>
</div>
);
}
export default PatternEditor;

View File

@@ -1,78 +0,0 @@
import { Input } from '@signozhq/ui/input';
import { Lock } from '@signozhq/icons';
import ExtraPricingBuckets from './ExtraPricingBuckets';
import { parsePricingAmount } from './utils';
import type { DrawerDraft } from './types';
type Pricing = DrawerDraft['pricing'];
interface PricingFieldsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
function PricingFields({
pricing,
isReadOnly,
onChange,
}: PricingFieldsProps): JSX.Element {
return (
<div className="drawer-section drawer-surface">
<div className="drawer-surface__head">
<h4>Pricing (per 1M tokens, USD)</h4>
{isReadOnly && (
<span className="managed-label" data-testid="drawer-readonly-label">
<Lock size={12} />
Read-only
</span>
)}
</div>
<div className="pricing-grid">
<div className="pricing-field">
<label htmlFor="input-cost">
Input cost <span className="required">*</span>
</label>
<Input
id="input-cost"
type="number"
min={0}
step={0.01}
value={pricing.input}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ input: parsePricingAmount(e.target.value) ?? 0 })
}
testId="drawer-input-cost"
/>
</div>
<div className="pricing-field">
<label htmlFor="output-cost">
Output cost <span className="required">*</span>
</label>
<Input
id="output-cost"
type="number"
min={0}
step={0.01}
value={pricing.output}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ output: parsePricingAmount(e.target.value) ?? 0 })
}
testId="drawer-output-cost"
/>
</div>
</div>
<ExtraPricingBuckets
pricing={pricing}
isReadOnly={isReadOnly}
onChange={onChange}
/>
</div>
);
}
export default PricingFields;

View File

@@ -1,110 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock } from '@signozhq/icons';
interface SourceSelectorProps {
isOverride: boolean;
isReadOnly: boolean;
disableAuto?: boolean;
onChange: (isOverride: boolean) => void;
}
// Auto-populated vs user-override selector, with a confirm step before
// discarding custom values back to defaults.
function SourceSelector({
isOverride,
isReadOnly,
disableAuto = false,
onChange,
}: SourceSelectorProps): JSX.Element {
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
const handleSourceChange = (value: 'auto' | 'override'): void => {
if (value === 'auto' && isOverride) {
setShowResetConfirm(true);
return;
}
if (value === 'override' && !isOverride) {
onChange(true);
}
};
const confirmReset = (): void => {
onChange(false);
setShowResetConfirm(false);
};
return (
<div className="drawer-section drawer-surface">
<div className="drawer-surface__head">
<h4>Source</h4>
{isReadOnly && (
<span className="managed-label" data-testid="drawer-managed-label">
<Lock size={12} />
Managed by SigNoz
</span>
)}
</div>
<RadioGroup
value={isOverride ? 'override' : 'auto'}
onChange={(value): void => handleSourceChange(value as 'auto' | 'override')}
className="source-radio-group"
>
<RadioGroupItem
value="auto"
containerClassName="source-radio source-radio--auto"
testId="drawer-source-auto"
disabled={disableAuto}
>
<div className="source-radio__title">Auto-populated</div>
<div className="source-radio__desc">
{disableAuto
? 'Available once SigNoz has default pricing for this model.'
: 'Default pricing from SigNoz.'}
</div>
</RadioGroupItem>
<RadioGroupItem
value="override"
containerClassName="source-radio source-radio--override"
testId="drawer-source-override"
>
<div className="source-radio__title">User override</div>
<div className="source-radio__desc">Custom pricing. Takes precedence.</div>
</RadioGroupItem>
</RadioGroup>
{showResetConfirm && (
<div
className="reset-confirm"
role="dialog"
aria-label="Reset to default pricing"
>
<p>
Reset to default pricing? Custom values will be discarded. it might take
24 hours for changes to take effect.
</p>
<div className="reset-confirm__actions">
<Button
variant="outlined"
color="secondary"
onClick={(): void => setShowResetConfirm(false)}
testId="drawer-reset-keep-btn"
>
Keep
</Button>
<Button
variant="solid"
color="primary"
onClick={confirmReset}
testId="drawer-reset-confirm-btn"
>
Reset
</Button>
</div>
</div>
)}
</div>
);
}
export default SourceSelector;

View File

@@ -1,194 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import LLMObservabilityModelPricing from '../LLMObservabilityModelPricing';
const ENDPOINT = '*/api/v1/llm_pricing_rules';
const mockRules: LlmpricingruletypesLLMPricingRuleDTO[] = [
{
id: 'rule-gpt4o',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
},
{
id: 'rule-llama',
orgId: 'org-1',
modelName: 'llama-3.1-70b',
provider: 'Self-hosted',
modelPattern: ['llama-3.1'],
isOverride: true,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 0, output: 0 },
},
];
describe('LLMObservabilityModelPricing', () => {
beforeEach(() => {
server.use(
rest.get(ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
items: mockRules,
limit: 100,
offset: 0,
total: mockRules.length,
},
}),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page header and both rules', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
expect(screen.getByText('Configuration')).toBeInTheDocument();
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
});
it('search is server-driven: typing sends the q param to the list request', async () => {
const requestedQ: (string | null)[] = [];
server.use(
rest.get(ENDPOINT, (req, res, ctx) => {
requestedQ.push(req.url.searchParams.get('q'));
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: { items: mockRules, limit: 20, offset: 0, total: mockRules.length },
}),
);
}),
);
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'llama' },
});
// Debounced, so the request fires shortly after typing stops.
await waitFor(() => expect(requestedQ).toContain('llama'));
});
// TODO: drive this through the actual source <SelectSimple> UI (open the
// dropdown + click "User override") instead of seeding the URL. The radix
// Select popover doesn't open under jsdom, so for now we assert the wiring
// via the URL param. Fix once we have a reliable way to interact with the
// Select in tests.
it('source filter is server-driven: the URL source param is sent to the list request', async () => {
const requestedSource: (string | null)[] = [];
server.use(
rest.get(ENDPOINT, (req, res, ctx) => {
requestedSource.push(req.url.searchParams.get('source'));
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: { items: mockRules, limit: 20, offset: 0, total: mockRules.length },
}),
);
}),
);
render(
<LLMObservabilityModelPricing />,
{},
{ initialRoute: '/?source=override' },
);
await screen.findByText('gpt-4o');
await waitFor(() => expect(requestedSource).toContain('override'));
});
it('opens the drawer in Add mode when the Add button is clicked', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.click(screen.getByTestId('add-model-cost-btn'));
const input = (await screen.findByTestId(
'drawer-model-id-input',
)) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe('');
});
it('opens the drawer in Edit mode with prefilled values when a row Edit is clicked', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.click(screen.getByTestId('edit-rule-rule-gpt4o'));
const input = (await screen.findByTestId(
'drawer-model-id-input',
)) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe('gpt-4o');
});
it('hides the Add button for non-admin users (write APIs are Admin-only)', async () => {
render(<LLMObservabilityModelPricing />, {}, { role: 'VIEWER' });
await screen.findByText('gpt-4o');
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
// rows still open in read-only "View" mode
expect(screen.getByTestId('edit-rule-rule-gpt4o')).toHaveTextContent('View');
});
it('paginates server-side: selecting page 2 requests the next offset', async () => {
const requestedOffsets: number[] = [];
server.use(
rest.get(ENDPOINT, (req, res, ctx) => {
const offset = Number(req.url.searchParams.get('offset') ?? '0');
requestedOffsets.push(offset);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
items: [
{ ...mockRules[0], id: `rule-${offset}`, modelName: `model-${offset}` },
],
limit: 20,
offset,
total: 25,
},
}),
);
}),
);
render(<LLMObservabilityModelPricing />);
await screen.findByText('model-0');
fireEvent.click(screen.getByRole('button', { name: '2' }));
await screen.findByText('model-20');
expect(requestedOffsets).toContain(20);
});
});

View File

@@ -1,302 +0,0 @@
import { useState } from 'react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from 'tests/test-utils';
import { EMPTY_DRAFT } from '../constants';
import type { DrawerDraft } from '../types';
import ModelCostDrawer from '../ModelCostDrawer';
interface HarnessProps {
initialDraft?: DrawerDraft;
mode?: 'add' | 'edit';
canManage?: boolean;
onSave?: () => void;
onDelete?: () => void;
}
function Harness({
initialDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 1 },
},
mode = 'add',
canManage = true,
onSave = jest.fn(),
onDelete = jest.fn(),
}: HarnessProps): JSX.Element {
const [draft, setDraft] = useState<DrawerDraft>(initialDraft);
return (
<ModelCostDrawer
isOpen
mode={mode}
draft={draft}
setDraft={setDraft}
onClose={jest.fn()}
onSave={onSave}
onDelete={onDelete}
isSaving={false}
isDeleting={false}
saveError={null}
canManage={canManage}
/>
);
}
describe('ModelCostDrawer', () => {
it('adds a pattern chip when the user types and presses Enter', () => {
render(<Harness />);
fireEvent.change(screen.getByTestId('drawer-pattern-input'), {
target: { value: 'gpt-4o-mini' },
});
fireEvent.keyDown(screen.getByTestId('drawer-pattern-input'), {
key: 'Enter',
code: 'Enter',
});
expect(screen.getByText('gpt-4o-mini*')).toBeInTheDocument();
});
it('disables pricing fields when isOverride is false', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: false,
}}
/>,
);
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
expect(screen.getByTestId('drawer-output-cost')).toBeDisabled();
});
it('enables pricing fields when isOverride is true', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-input-cost')).not.toBeDisabled();
expect(screen.getByTestId('drawer-output-cost')).not.toBeDisabled();
});
it('shows only the Add pricing bucket button when no extra buckets are set', () => {
render(<Harness mode="add" />);
expect(screen.getByTestId('drawer-add-bucket-btn')).toBeInTheDocument();
expect(
screen.queryByTestId('drawer-cache-read-cost'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('drawer-cache-write-cost'),
).not.toBeInTheDocument();
});
it('adds a cache bucket from the picker', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<Harness mode="add" />);
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
// Picker offers both cache buckets…
expect(
screen.getByTestId('drawer-add-bucket-cache-read'),
).toBeInTheDocument();
expect(
screen.getByTestId('drawer-add-bucket-cache-write'),
).toBeInTheDocument();
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
// …and picking one reveals its price input.
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
});
it('removes an existing cache bucket', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 1, cacheRead: 0.5 },
}}
/>,
);
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
await user.click(screen.getByTestId('drawer-remove-cache-read'));
expect(
screen.queryByTestId('drawer-cache-read-cost'),
).not.toBeInTheDocument();
});
it('hides bucket add/remove controls in read-only mode but shows set buckets', () => {
render(
<Harness
mode="edit"
canManage={false}
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
pricing: { ...EMPTY_DRAFT.pricing, cacheRead: 0.5 },
}}
/>,
);
expect(screen.getByTestId('drawer-cache-read-cost')).toBeDisabled();
expect(screen.queryByTestId('drawer-add-bucket-btn')).not.toBeInTheDocument();
expect(
screen.queryByTestId('drawer-remove-cache-read'),
).not.toBeInTheDocument();
});
it('disables the Provider select in Edit mode but allows it in Add mode', () => {
const { unmount } = render(<Harness mode="add" />);
expect(screen.getByTestId('drawer-provider-select')).not.toHaveAttribute(
'data-disabled',
);
unmount();
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-provider-select')).toHaveAttribute(
'data-disabled',
);
});
it('keeps metadata editable but locks pricing when source is auto-populated', () => {
render(
<Harness
mode="add"
initialDraft={{ ...EMPTY_DRAFT, modelName: 'gpt-4o', isOverride: false }}
/>,
);
// Metadata stays editable while the rule is auto-populated…
expect(screen.getByTestId('drawer-model-id-input')).not.toBeDisabled();
// …but pricing is read-only until "User override" is chosen.
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
});
it('shows a reset confirmation when switching from Override to Auto', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
await user.click(screen.getByTestId('drawer-source-auto'));
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
expect(screen.getByTestId('drawer-reset-keep-btn')).toBeInTheDocument();
});
it('disables the Auto-populated source option in Add mode', () => {
render(<Harness mode="add" />);
expect(screen.getByTestId('drawer-source-auto')).toBeDisabled();
});
it('enables the Auto-populated source option in Edit mode', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-source-auto')).not.toBeDisabled();
});
it('hides the Delete action in Add mode', () => {
render(<Harness mode="add" />);
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
});
it('shows the Delete action in Edit mode', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-delete-btn')).toBeInTheDocument();
});
it('calls onSave when the Save button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(<Harness onSave={onSave} />);
await user.click(screen.getByTestId('drawer-save-btn'));
expect(onSave).toHaveBeenCalledTimes(1);
});
it('is read-only when the user cannot manage pricing (hides Save/Delete)', () => {
render(
<Harness
mode="edit"
canManage={false}
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
expect(screen.getByTestId('drawer-model-id-input')).toBeDisabled();
});
});

View File

@@ -1,149 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EMPTY_DRAFT } from '../constants';
import type { DrawerDraft, PricingRule } from '../types';
import {
buildPricingPayload,
buildRulePayload,
draftFromRule,
validateDraft,
} from '../utils';
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
id: 'rule-1',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
...overrides,
});
describe('drawer draft utils', () => {
describe('draftFromRule', () => {
it('maps a rule to a draft with cache values when present', () => {
const rule = makeRule({
pricing: {
input: 3,
output: 15,
cache: {
mode: CacheModeDTO.additive,
read: 0.3,
write: 3.75,
},
},
});
const draft = draftFromRule(rule);
expect(draft.modelName).toBe('gpt-4o');
expect(draft.pricing.input).toBe(3);
expect(draft.pricing.cacheRead).toBe(0.3);
expect(draft.pricing.cacheWrite).toBe(3.75);
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.additive);
});
it('falls back to defaults when cache is missing', () => {
const draft = draftFromRule(makeRule());
expect(draft.pricing.cacheRead).toBeNull();
expect(draft.pricing.cacheWrite).toBeNull();
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.unknown);
});
});
describe('buildPricingPayload', () => {
it('omits the cache block when no cache values are set', () => {
const payload = buildPricingPayload(EMPTY_DRAFT);
expect(payload.cache).toBeUndefined();
});
it('includes only the cache values that are > 0', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
pricing: {
...EMPTY_DRAFT.pricing,
cacheRead: 1.5,
cacheWrite: 0,
cacheMode: CacheModeDTO.subtract,
},
};
const payload = buildPricingPayload(draft);
expect(payload.cache?.read).toBe(1.5);
expect(payload.cache?.write).toBeUndefined();
expect(payload.cache?.mode).toBe(CacheModeDTO.subtract);
});
});
describe('buildRulePayload', () => {
it('uses the modelName as a default pattern when no patterns are supplied', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
patterns: [],
provider: 'OpenAI',
};
const payload = buildRulePayload(draft);
expect(payload.modelPattern).toStrictEqual(['gpt-4o']);
expect(payload.unit).toBe(UnitDTO.per_million_tokens);
expect(payload.enabled).toBe(true);
});
it('omits id and sourceId for an Add draft', () => {
const payload = buildRulePayload(EMPTY_DRAFT);
expect(payload.id).toBeUndefined();
expect(payload.sourceId).toBeUndefined();
});
});
describe('validateDraft', () => {
it('requires a model name in Add mode', () => {
const result = validateDraft(EMPTY_DRAFT, 'add');
expect(result.ok).toBe(false);
expect(result.message).toMatch(/billing model id/i);
});
it('rejects negative pricing', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: -1 },
};
expect(validateDraft(draft, 'add').ok).toBe(false);
});
it('accepts a valid Add draft', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 2 },
};
expect(validateDraft(draft, 'add').ok).toBe(true);
});
it('rejects zero input/output cost for overrides', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
isOverride: true,
pricing: { ...EMPTY_DRAFT.pricing, input: 0, output: 5 },
};
const result = validateDraft(draft, 'add');
expect(result.ok).toBe(false);
expect(result.message).toMatch(/input cost must be greater than 0/i);
});
it('skips pricing validation for auto-populated (non-override) rules', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
isOverride: false,
pricing: { ...EMPTY_DRAFT.pricing, input: 0, output: 0 },
};
expect(validateDraft(draft, 'edit').ok).toBe(true);
});
});
});

View File

@@ -1,90 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PricingRule } from '../types';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
} from '../utils';
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
id: 'rule-1',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
...overrides,
});
describe('utils', () => {
describe('formatPricePerMillion', () => {
it('formats numbers with 2 decimals and dollar prefix', () => {
expect(formatPricePerMillion(15)).toBe('$15.00');
expect(formatPricePerMillion(0.15)).toBe('$0.15');
});
it('returns em-dash for nullish or NaN', () => {
expect(formatPricePerMillion(undefined)).toBe('—');
expect(formatPricePerMillion(Number.NaN)).toBe('—');
});
});
describe('getExtraBuckets', () => {
it('returns an empty array when there is no cache pricing', () => {
expect(getExtraBuckets(makeRule())).toStrictEqual([]);
});
it('returns only buckets with values > 0', () => {
const rule = makeRule({
pricing: {
input: 3,
output: 15,
cache: {
mode: CacheModeDTO.additive,
read: 0.3,
write: 0,
},
},
});
const buckets = getExtraBuckets(rule);
expect(buckets).toStrictEqual([{ key: 'cache_read', pricePerMillion: 0.3 }]);
});
});
describe('getSourceLabel', () => {
it('returns "Auto" for non-override and "User override" otherwise', () => {
expect(getSourceLabel(makeRule({ isOverride: false }))).toBe('Auto');
expect(getSourceLabel(makeRule({ isOverride: true }))).toBe('User override');
});
});
describe('getCanonicalId', () => {
it('lowercases the provider and joins with the model name', () => {
expect(getCanonicalId(makeRule({ provider: 'OpenAI' }))).toBe(
'openai:gpt-4o',
);
});
});
describe('getRelativeLastSeen', () => {
it('returns em-dash when no timestamp is present', () => {
expect(getRelativeLastSeen(makeRule())).toBe('—');
});
it('formats minutes-old timestamps via dayjs fromNow', () => {
const recent = new Date(Date.now() - 5 * 60 * 1000).toISOString();
expect(getRelativeLastSeen(makeRule({ updatedAt: recent }))).toMatch(
/minutes? ago/,
);
});
});
});

View File

@@ -1,41 +0,0 @@
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import type { CacheBucketDef, DrawerDraft } from './types';
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
{ value: 'Google', label: 'Google' },
{ value: 'Self-hosted', label: 'Self-hosted' },
{ value: 'Other', label: 'Other' },
];
export const CACHE_MODE_OPTIONS = [
{ value: CacheModeDTO.subtract, label: 'Subtract (OpenAI style)' },
{ value: CacheModeDTO.additive, label: 'Additive (Anthropic style)' },
{ value: CacheModeDTO.unknown, label: 'Unknown' },
];
// Optional buckets offered in the "Add pricing bucket" picker. Only the cache
// buckets are persisted by the API today (pricing.cache.read/write).
export const CACHE_BUCKETS: CacheBucketDef[] = [
{ key: 'cacheRead', label: 'cache_read', testId: 'cache-read' },
{ key: 'cacheWrite', label: 'cache_write', testId: 'cache-write' },
];
export const EMPTY_DRAFT: DrawerDraft = {
id: null,
sourceId: null,
modelName: '',
provider: 'OpenAI',
patterns: [],
isOverride: true,
pricing: {
input: 0,
output: 0,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
},
};

View File

@@ -1,55 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type ListLLMPricingRulesParams,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
export type SourceFilter = 'all' | 'auto' | 'override';
// List request params. Extends the generated (offset/limit) params with the
// search/source filters — backend support is pending, but sending them now
// keeps the page backend-driven so no FE change is needed once it lands.
export type ListModelPricingParams = ListLLMPricingRulesParams & {
q?: string;
source?: Exclude<SourceFilter, 'all'>;
};
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}
export type DrawerMode = 'add' | 'edit';
// Optional pricing buckets the user can add/remove. Keyed by the matching
// DrawerDraft['pricing'] field.
export type CacheBucketKey = 'cacheRead' | 'cacheWrite';
export interface CacheBucketDef {
key: CacheBucketKey;
label: string;
testId: string;
}
export interface DrawerDraft {
id: string | null;
sourceId: string | null;
modelName: string;
provider: string;
patterns: string[];
isOverride: boolean;
pricing: {
input: number;
output: number;
cacheMode: CacheModeDTO;
cacheRead: number | null;
cacheWrite: number | null;
};
}
export interface ValidationResult {
ok: boolean;
message?: string;
}

View File

@@ -1,123 +0,0 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useCreateOrUpdateLLMPricingRules,
useDeleteLLMPricingRule,
} from 'api/generated/services/llmpricingrules';
import { EMPTY_DRAFT } from './constants';
import type { DrawerDraft, DrawerMode, PricingRule } from './types';
import { buildRulePayload, draftFromRule } from './utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
mode: DrawerMode;
draft: DrawerDraft;
setDraft: (next: DrawerDraft) => void;
openForAdd: (prefillModelName?: string) => void;
openForEdit: (rule: PricingRule) => void;
close: () => void;
save: () => Promise<void>;
deleteRule: () => Promise<void>;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
selectedRuleId: string | null;
}
export function useModelCostDrawer(): UseModelCostDrawerResult {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<DrawerMode>('add');
const [draft, setDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
useDeleteLLMPricingRule();
const invalidateList = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
}, [queryClient]);
const openForAdd = useCallback((prefillModelName?: string): void => {
setMode('add');
setDraft({
...EMPTY_DRAFT,
modelName: prefillModelName || '',
patterns: prefillModelName ? [prefillModelName] : [],
});
setSelectedRuleId(null);
setSaveError(null);
setIsOpen(true);
}, []);
const openForEdit = useCallback((rule: PricingRule): void => {
setMode('edit');
setDraft(draftFromRule(rule));
setSelectedRuleId(rule.id);
setSaveError(null);
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
setSelectedRuleId(null);
setSaveError(null);
}, []);
const save = useCallback(async (): Promise<void> => {
setSaveError(null);
try {
await createOrUpdate({
data: { rules: [buildRulePayload(draft)] },
});
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
}
}, [createOrUpdate, draft, invalidateList, mode]);
const deleteRule = useCallback(async (): Promise<void> => {
if (!draft.id) {
return;
}
setSaveError(null);
try {
await deleteRuleApi({ pathParams: { id: draft.id } });
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success('Model cost deleted');
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed';
setSaveError(message);
}
}, [deleteRuleApi, draft.id, invalidateList]);
return {
isOpen,
mode,
draft,
setDraft,
openForAdd,
openForEdit,
close,
save,
deleteRule,
isSaving,
isDeleting,
saveError,
selectedRuleId,
};
}

View File

@@ -1,74 +0,0 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import useUrlQuery from 'hooks/useUrlQuery';
import type { SourceFilter } from './types';
const SEARCH_KEY = 'q';
const SOURCE_KEY = 'source';
const PAGE_KEY = 'page';
const DEFAULT_SOURCE: SourceFilter = 'all';
export interface ModelPricingFilters {
search: string;
source: SourceFilter;
page: number;
setSearch: (value: string) => void;
setSource: (value: SourceFilter) => void;
setPage: (value: number) => void;
}
const isSourceFilter = (value: string | null): value is SourceFilter =>
value === 'all' || value === 'auto' || value === 'override';
// Keeps the model-cost list filters (search / source / page) in the URL so the
// view is shareable and reload-safe. These map straight onto the list request
// params (q, source, offset), making the table backend-driven once the API
// honours them.
export function useModelPricingFilters(): ModelPricingFilters {
const history = useHistory();
const urlQuery = useUrlQuery();
const search = urlQuery.get(SEARCH_KEY) ?? '';
const sourceParam = urlQuery.get(SOURCE_KEY);
const source = isSourceFilter(sourceParam) ? sourceParam : DEFAULT_SOURCE;
const page = Math.max(1, Number(urlQuery.get(PAGE_KEY)) || 1);
const setParam = useCallback(
(key: string, value: string | null, resetPage: boolean): void => {
const next = new URLSearchParams(urlQuery.toString());
if (value) {
next.set(key, value);
} else {
next.delete(key);
}
// Filter changes invalidate the current page offset.
if (resetPage) {
next.delete(PAGE_KEY);
}
history.replace({ search: next.toString() });
},
[history, urlQuery],
);
const setSearch = useCallback(
(value: string): void => setParam(SEARCH_KEY, value.trim(), true),
[setParam],
);
const setSource = useCallback(
(value: SourceFilter): void =>
// 'all' is the default, so keep it out of the URL.
setParam(SOURCE_KEY, value === DEFAULT_SOURCE ? null : value, true),
[setParam],
);
const setPage = useCallback(
(value: number): void =>
setParam(PAGE_KEY, value <= 1 ? null : String(value), false),
[setParam],
);
return { search, source, page, setSearch, setSource, setPage };
}

View File

@@ -1,162 +0,0 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingCacheCostsDTO,
type LlmpricingruletypesLLMRulePricingDTO,
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type {
DrawerDraft,
DrawerMode,
ExtraBucket,
PricingRule,
ValidationResult,
} from './types';
// Idempotent — relativeTime is also extended globally in utils/timeUtils.
dayjs.extend(relativeTime);
const lc = (value: string): string => value.toLowerCase();
const hasCacheValue = (value: number | null): boolean =>
typeof value === 'number' && value > 0;
// ─── Input helpers ───────────────────────────────────────────────────────────
// Parses a price input's raw string. Empty → null (used by optional buckets),
// otherwise a finite number (NaN coerced to 0).
export const parsePricingAmount = (raw: string): number | null => {
if (raw.trim() === '') {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
};
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
if (value === undefined || value === null || Number.isNaN(value)) {
return '—';
}
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
rule.isOverride ? 'User override' : 'Auto';
export const getRelativeLastSeen = (rule: PricingRule): string => {
const ts = rule.updatedAt || rule.syncedAt || rule.createdAt;
const parsed = ts ? dayjs(ts) : null;
return parsed?.isValid() ? parsed.fromNow() : '—';
};
export const getCanonicalId = (rule: PricingRule): string => {
const provider = rule.provider?.trim() || 'unknown';
return `${lc(provider)}:${rule.modelName}`;
};
// ─── Drawer draft <-> API helpers ────────────────────────────────────────────
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
id: rule.id,
sourceId: rule.sourceId ?? null,
modelName: rule.modelName,
provider: rule.provider || 'OpenAI',
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
cacheRead: rule.pricing?.cache?.read ?? null,
cacheWrite: rule.pricing?.cache?.write ?? null,
},
});
export const buildPricingPayload = (
draft: DrawerDraft,
): LlmpricingruletypesLLMRulePricingDTO => {
const pricing: LlmpricingruletypesLLMRulePricingDTO = {
input: draft.pricing.input,
output: draft.pricing.output,
};
if (
hasCacheValue(draft.pricing.cacheRead) ||
hasCacheValue(draft.pricing.cacheWrite)
) {
const cache: LlmpricingruletypesLLMPricingCacheCostsDTO = {
mode: draft.pricing.cacheMode,
};
if (hasCacheValue(draft.pricing.cacheRead)) {
cache.read = draft.pricing.cacheRead as number;
}
if (hasCacheValue(draft.pricing.cacheWrite)) {
cache.write = draft.pricing.cacheWrite as number;
}
pricing.cache = cache;
}
return pricing;
};
export const buildRulePayload = (
draft: DrawerDraft,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
id: draft.id || undefined,
sourceId: draft.sourceId || undefined,
modelName: draft.modelName.trim(),
provider: draft.provider.trim(),
modelPattern:
draft.patterns.length > 0 ? draft.patterns : [draft.modelName.trim()],
isOverride: draft.isOverride,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
export const validateDraft = (
draft: DrawerDraft,
mode: DrawerMode,
): ValidationResult => {
if (mode === 'add' && !draft.modelName.trim()) {
return { ok: false, message: 'Billing model ID is required.' };
}
if (!draft.provider.trim()) {
return { ok: false, message: 'Provider is required.' };
}
// Pricing is only user-entered for overrides; auto-populated rules are
// managed by SigNoz (and may legitimately be 0 for self-hosted models).
if (draft.isOverride) {
if (!(draft.pricing.input > 0)) {
return { ok: false, message: 'Input cost must be greater than 0.' };
}
if (!(draft.pricing.output > 0)) {
return { ok: false, message: 'Output cost must be greater than 0.' };
}
if (
(draft.pricing.cacheRead !== null && draft.pricing.cacheRead < 0) ||
(draft.pricing.cacheWrite !== null && draft.pricing.cacheWrite < 0)
) {
return { ok: false, message: 'Cache costs must be non-negative.' };
}
}
return { ok: true };
};

View File

@@ -151,11 +151,6 @@ export function PlannedDowntimeForm(
const saveHandler = useCallback(
async (values: PlannedDowntimeFormData) => {
const { startTime, timezone } = values;
if (!startTime || !timezone) {
// unreachable: required fields should always be present on submitting.
return;
}
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
alertIds:
values.alertRuleScope === 'all'
@@ -166,9 +161,9 @@ export function PlannedDowntimeForm(
name: values.name,
scope: values.scope,
schedule: {
startTime: startTime.format(),
startTime: values.startTime?.format(),
endTime: values.endTime?.format(),
timezone,
timezone: values.timezone!,
recurrence: values.recurrence,
},
};
@@ -205,17 +200,25 @@ export function PlannedDowntimeForm(
],
);
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
const rec = values.recurrence;
const recurrence =
rec && rec.repeatType !== recurrenceOptions.doesNotRepeat.value
? {
duration: `${rec.duration}${durationUnit}`,
repeatOn: rec.repeatOn,
repeatType: rec.repeatType,
}
: undefined;
const { recurrence } = values;
const recurrenceData =
!recurrence ||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
? undefined
: {
duration: recurrence.duration
? `${recurrence.duration}${durationUnit}`
: '',
startTime: values.startTime!.format(),
endTime: values.endTime?.format(),
repeatOn: recurrence.repeatOn,
repeatType: recurrence.repeatType,
};
await saveHandler({ ...values, recurrence });
await saveHandler({
...values,
recurrence: recurrenceData,
});
};
const handleFormData = (data: Partial<PlannedDowntimeFormData>): void => {
@@ -272,6 +275,9 @@ export function PlannedDowntimeForm(
const formattedInitialValues = useMemo((): PlannedDowntimeFormData => {
const { schedule } = initialValues;
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
const initialAlertIds = initialValues.alertIds || [];
return {
@@ -279,12 +285,8 @@ export function PlannedDowntimeForm(
alertRuleScope:
isEditMode && initialAlertIds.length === 0 ? 'all' : 'specific',
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
startTime: schedule?.startTime
? dayjs(schedule.startTime).tz(schedule.timezone)
: null,
endTime: schedule?.endTime
? dayjs(schedule.endTime).tz(schedule.timezone)
: null,
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
recurrence: {
...schedule?.recurrence,
repeatType: !isScheduleRecurring(schedule)
@@ -295,7 +297,7 @@ export function PlannedDowntimeForm(
timezone: schedule?.timezone as string,
scope: initialValues.scope || '',
};
}, [initialValues, isEditMode, alertOptions]);
}, [initialValues, alertOptions]);
useEffect(() => {
setSelectedTags(formattedInitialValues.alertRules);
@@ -339,7 +341,7 @@ export function PlannedDowntimeForm(
const formattedEndTime = endTime.format(TIME_FORMAT);
const formattedEndDate = endTime.format(DATE_FORMAT);
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
}, [formData]);
}, [formData, recurrenceType]);
return (
<Modal

View File

@@ -142,6 +142,7 @@ export function CollapseListContent({
updated_by_name?: string;
alertOptions?: DefaultOptionType[];
}): JSX.Element {
const repeats = schedule?.recurrence;
const renderItems = (title: string, value: ReactNode): JSX.Element => (
<div className="render-item-collapse-list">
<Typography>{title}</Typography>
@@ -192,7 +193,10 @@ export function CollapseListContent({
'Timezone',
<Typography>{schedule?.timezone || '-'}</Typography>,
)}
{renderItems('Repeats', <Typography>{recurrenceInfo(schedule)}</Typography>)}
{renderItems(
'Repeats',
<Typography>{recurrenceInfo(repeats, schedule?.timezone)}</Typography>,
)}
{renderItems(
'Alerts silenced',
alertOptions?.length ? (

View File

@@ -6,7 +6,7 @@ import type {
DeleteDowntimeScheduleByIDPathParameters,
RenderErrorResponseDTO,
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesScheduleDTO,
AlertmanagertypesRecurrenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
@@ -66,17 +66,14 @@ export const getAlertOptionsFromIds = (
);
export const recurrenceInfo = (
schedule?: AlertmanagertypesScheduleDTO | null,
recurrence?: AlertmanagertypesRecurrenceDTO | null,
timezone?: string,
): string => {
if (!schedule) {
return 'No';
}
const { startTime, endTime, timezone, recurrence } = schedule;
if (!recurrence) {
return 'No';
}
const { duration, repeatOn, repeatType } = recurrence;
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
const formattedStartTime = startTime
? formatDateTime(startTime, timezone)
@@ -98,7 +95,7 @@ export const defaultInitialValues: Partial<AlertmanagertypesPlannedMaintenanceDT
timezone: '',
endTime: undefined,
recurrence: undefined,
startTime: '',
startTime: undefined,
},
alertIds: [],
createdAt: undefined,

View File

@@ -11,7 +11,7 @@ export const buildSchedule = (
schedule: Partial<AlertmanagertypesScheduleDTO>,
): AlertmanagertypesScheduleDTO => ({
timezone: schedule?.timezone ?? '',
startTime: schedule?.startTime ?? '',
startTime: schedule?.startTime,
endTime: schedule?.endTime,
recurrence: schedule?.recurrence,
});

View File

@@ -142,15 +142,6 @@
}
}
.reset-password-back-action {
margin-top: var(--spacing-12);
width: 100%;
button {
width: 100%;
}
}
@media (max-width: 768px) {
width: 100%;
padding: 0 16px;

View File

@@ -1,10 +1,7 @@
import { ArrowLeft, CircleAlert } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { CircleAlert } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import APIError from 'types/api/error';
import './ResetPassword.styles.scss';
@@ -62,16 +59,6 @@ function TokenError({ error }: TokenErrorProps): JSX.Element {
</Typography.Text>
</div>
{error && <AuthError error={error} />}
<div className="reset-password-back-action">
<Button
variant="solid"
data-testid="back-to-login"
prefix={<ArrowLeft size={12} />}
onClick={(): void => history.push(ROUTES.LOGIN)}
>
Back to login
</Button>
</div>
</div>
</AuthPageContainer>
);

View File

@@ -119,10 +119,6 @@
border-radius: 0px 4px 4px 0px;
background: var(--l3-background);
&.version-container-standalone {
border-radius: 4px;
}
}
.version {
@@ -1135,9 +1131,17 @@
.settings-dropdown,
.help-support-dropdown {
.user-settings-dropdown-logout-section {
color: var(--danger-background);
pointer-events: auto;
.ant-dropdown-menu-item {
min-height: 32px;
.ant-dropdown-menu-title-content {
color: var(--l1-foreground) !important;
}
.user-settings-dropdown-logout-section {
color: var(--danger-background);
pointer-events: auto;
}
}
}

View File

@@ -1010,7 +1010,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
<img src={signozBrandLogoUrl} alt="SigNoz" />
</div>
{(licenseTag || currentVersion) && (
{licenseTag && (
<div
className={cx(
'brand-title-section',
@@ -1021,7 +1021,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
'version-update-notification',
)}
>
{licenseTag && <span className="license-type"> {licenseTag} </span>}
<span className="license-type"> {licenseTag} </span>
{currentVersion && (
<Tooltip
@@ -1043,12 +1043,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
)
}
>
<div
className={cx(
'version-container',
!licenseTag && 'version-container-standalone',
)}
>
<div className="version-container">
<span
className={cx('version', changelog && 'version-clickable')}
onClick={onClickVersionHandler}

View File

@@ -206,7 +206,6 @@ export const routesToSkip = [
ROUTES.METER,
ROUTES.METER_EXPLORER_VIEWS,
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -1,42 +0,0 @@
import { getFlamegraph } from 'api/generated/services/tracedetail';
import {
SpantypesGettableFlamegraphTraceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryResult } from 'react-query';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export interface GetTraceFlamegraphV3Props {
traceId: string;
selectedSpanId?: string;
selectFields?: TelemetryFieldKey[];
enabled?: boolean;
}
const useGetTraceFlamegraphV3 = (
props: GetTraceFlamegraphV3Props,
): UseQueryResult<SpantypesGettableFlamegraphTraceDTO, unknown> =>
useQuery({
queryFn: () =>
getFlamegraph(
{ traceID: props.traceId },
{
selectedSpanId: props.selectedSpanId,
// v5 TelemetryFieldKey and the generated DTO are runtime-identical; only
// the literal-union vs enum nominal types differ
selectFields: props.selectFields as TelemetrytypesTelemetryFieldKeyDTO[],
},
).then((res) => res.data),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V3_FLAMEGRAPH,
props.traceId,
props.selectedSpanId,
props.selectFields,
],
enabled: props.enabled,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
export default useGetTraceFlamegraphV3;

View File

@@ -22,13 +22,11 @@ interface CacheEntry {
const CACHE_SIZE_LIMIT = 1000;
const CACHE_CLEANUP_PERCENTAGE = 0.5; // Remove 50% when limit is reached
export type FormatTimezoneAdjustedTimestamp = (
input: TimestampInput,
format?: string,
) => string;
function useTimezoneFormatter({ userTimezone }: { userTimezone: Timezone }): {
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp;
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string;
} {
// Initialize cache using useMemo to persist between renders
const cache = useMemo(() => new Map<string, CacheEntry>(), []);

View File

@@ -1,5 +0,0 @@
export const STORAGE_KEY_PREFIX = 'qb_recent_v1';
export const STORAGE_VERSION = 1;
// Maximum entries kept per (signal, source) bucket. Larger than the UI's
// RECENTS_DISPLAY_CAP so deleting a visible entry surfaces an older one.
export const MAX_ENTRIES = 10;

View File

@@ -1,15 +0,0 @@
import type { SignalType } from 'types/api/v5/queryRange';
import * as store from './recentQueriesStore';
import type { RecentQueryEntry } from './types';
// Synchronous, non-subscribing read of the recent-queries bucket for a given
// (signal, source). Read-on-demand by design — subscribing here would
// reconfigure CodeMirror on every store change and close any open completion
// popup. Pair with saveQuery() for the write path.
export function getRecentQueries(
signal: SignalType,
source = '',
): RecentQueryEntry[] {
return store.list(signal, source);
}

View File

@@ -1,100 +0,0 @@
import { normalizeFilterExpression } from './normalize';
describe('normalizeFilterExpression', () => {
it('returns empty string for empty input', () => {
expect(normalizeFilterExpression('')).toBe('');
});
it('returns empty string for whitespace-only input', () => {
expect(normalizeFilterExpression(' \t ')).toBe('');
});
it('strips whitespace around operators', () => {
expect(normalizeFilterExpression(' a = 1 ')).toBe('a=1');
expect(normalizeFilterExpression('a = 1')).toBe('a=1');
expect(normalizeFilterExpression('a=1')).toBe('a=1');
});
it('lowercases AND / OR / NOT outside quotes', () => {
expect(normalizeFilterExpression('A AND B OR NOT C')).toBe('AandBornotC');
});
it('lowercases IN / LIKE / ILIKE / CONTAINS', () => {
expect(normalizeFilterExpression('host IN [1, 2] AND name LIKE "foo"')).toBe(
'hostin[1,2]andnamelike"foo"',
);
});
it('lowercases REGEXP', () => {
expect(normalizeFilterExpression('path REGEXP "foo"')).toBe(
'pathregexp"foo"',
);
expect(normalizeFilterExpression('path REGEXP "foo"')).toBe(
normalizeFilterExpression('path regexp "foo"'),
);
});
it('lowercases HAS / HASANY / HASALL / HASTOKEN function names', () => {
expect(normalizeFilterExpression('HAS(tags, "x")')).toBe(
normalizeFilterExpression('has(tags, "x")'),
);
expect(normalizeFilterExpression('HASANY(tags, ["a","b"])')).toBe(
normalizeFilterExpression('hasAny(tags, ["a","b"])'),
);
expect(normalizeFilterExpression('HASALL(tags, ["a","b"])')).toBe(
normalizeFilterExpression('hasAll(tags, ["a","b"])'),
);
expect(normalizeFilterExpression('HASTOKEN(msg, "err")')).toBe(
normalizeFilterExpression('hasToken(msg, "err")'),
);
});
it('lowercases TRUE / FALSE boolean literals', () => {
expect(normalizeFilterExpression('active = TRUE')).toBe(
normalizeFilterExpression('active = true'),
);
expect(normalizeFilterExpression('active = FALSE')).toBe(
normalizeFilterExpression('active = false'),
);
});
it('preserves whitespace and casing inside single-quoted strings', () => {
expect(normalizeFilterExpression("a = 'X Y'")).toBe("a='X Y'");
});
it('preserves whitespace and casing inside double-quoted strings', () => {
expect(normalizeFilterExpression('a = "X Y"')).toBe('a="X Y"');
});
it('does not lowercase keyword-looking substrings inside quotes', () => {
expect(normalizeFilterExpression("msg = 'AND ERROR'")).toBe(
"msg='AND ERROR'",
);
});
it('handles escaped quotes inside strings', () => {
expect(normalizeFilterExpression("msg = 'a\\'b' AND x = 1")).toBe(
"msg='a\\'b'andx=1",
);
});
it('treats two formattings of the same expression as identical', () => {
const a = normalizeFilterExpression(
'service.name = "frontend" AND severity = error',
);
const b = normalizeFilterExpression(
'service.name="frontend" and severity=error',
);
expect(a).toBe(b);
});
it('preserves unquoted value casing (treats them as identifiers)', () => {
expect(normalizeFilterExpression('status = OK')).toBe('status=OK');
});
it('handles mixed quotes in one expression', () => {
expect(normalizeFilterExpression(`a = 'X' AND b = "Y"`)).toBe(
`a='X'andb="Y"`,
);
});
});

View File

@@ -1,56 +0,0 @@
import {
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
TRACE_OPERATOR_OPERATORS,
} from 'constants/antlrQueryConstants';
// Reserved keywords sourced from the ANTLR grammar constants so this list stays
// in sync with the parser. `\b` prevents partial matches inside identifiers
// (e.g. `OR` inside `originator`). `TRUE`/`FALSE` are BOOL literals, included
// so case variants of boolean values also dedup.
const WORD_KEYWORDS = [
...Object.keys(OPERATORS).filter((k) => /^[A-Z]+$/.test(k)),
...Object.keys(TRACE_OPERATOR_OPERATORS).filter((k) => /^[A-Z]+$/.test(k)),
...Object.values(QUERY_BUILDER_FUNCTIONS),
'TRUE',
'FALSE',
];
const KEYWORDS_RE = new RegExp(`\\b(${WORD_KEYWORDS.join('|')})\\b`, 'gi');
// Matches single- or double-quoted string literals, supporting escaped quotes
// (e.g. `'it\'s'` or `"a \" b"`). We preserve quoted spans verbatim during
// normalisation so user-meaningful whitespace and casing inside string values
// stays intact: `name = "Foo Bar"` must not collapse to `name="foobar"`.
const QUOTED_RE = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g;
// Lowercases reserved keywords and strips ALL whitespace from the unquoted regions
// of the input. Keywords are normalised so casing variants dedup; whitespace is
// dropped so formatting variants (`a=1` vs `a = 1`) dedup too.
function processOutsideQuotes(s: string): string {
return s.replace(KEYWORDS_RE, (m) => m.toLowerCase()).replace(/\s+/g, '');
}
// Produces a canonical form of a filter expression suitable for dedup-key derivation.
// Walks the input alternating between unquoted regions (where we normalise keywords
// and whitespace) and quoted regions (which we copy verbatim).
export function normalizeFilterExpression(input: string): string {
if (!input) {
return '';
}
let result = '';
let lastIndex = 0;
QUOTED_RE.lastIndex = 0;
let match = QUOTED_RE.exec(input);
while (match !== null) {
result += processOutsideQuotes(input.slice(lastIndex, match.index));
result += match[0];
lastIndex = QUOTED_RE.lastIndex;
match = QUOTED_RE.exec(input);
}
result += processOutsideQuotes(input.slice(lastIndex));
return result.trim();
}

View File

@@ -1,247 +0,0 @@
import { MAX_ENTRIES } from './constants';
import * as store from './recentQueriesStore';
import type { RecentQueryInput } from './recentQueriesStore';
import type { RecentQueryEntry } from './types';
const baseInput = (
overrides: Partial<RecentQueryInput> = {},
): RecentQueryInput => ({
signal: 'logs',
filter: { expression: "service.name = 'frontend'" },
...overrides,
});
function saveOrThrow(input: RecentQueryInput): RecentQueryEntry {
const saved = store.save(input);
if (!saved) {
throw new Error('expected save to return an entry');
}
return saved;
}
describe('recentQueries store', () => {
beforeEach(() => {
store.useRecentQueriesStore.setState({ buckets: {} });
localStorage.clear();
});
describe('save + list', () => {
it('saves an entry and lists it', () => {
store.save(baseInput());
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe("service.name = 'frontend'");
expect(entries[0].id).toBeTruthy();
expect(entries[0].lastUsedAt).toBeGreaterThan(0);
});
it('does not save when the filter expression is empty', () => {
const result = store.save(baseInput({ filter: { expression: '' } }));
expect(result).toBeNull();
expect(store.list('logs')).toHaveLength(0);
});
it('does not save when the filter expression is whitespace only', () => {
const result = store.save(baseInput({ filter: { expression: ' ' } }));
expect(result).toBeNull();
expect(store.list('logs')).toHaveLength(0);
});
});
describe('LRU ordering', () => {
it('places the most recently saved entry at the front', () => {
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
store.save(baseInput({ filter: { expression: 'http.status_code >= 500' } }));
store.save(baseInput({ filter: { expression: 'attempt = 1' } }));
const entries = store.list('logs');
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
'attempt = 1',
'http.status_code >= 500',
"severity_text = 'ERROR'",
]);
});
it('re-saving an existing filter bumps it to the front', () => {
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
store.save(baseInput({ filter: { expression: 'http.status_code >= 500' } }));
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
const entries = store.list('logs');
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
"severity_text = 'ERROR'",
'http.status_code >= 500',
]);
});
});
describe('dedup', () => {
it('treats formatting variations of the same filter as one entry', () => {
store.save(
baseInput({
filter: { expression: "severity_text = 'ERROR' AND attempt = 1" },
}),
);
store.save(
baseInput({
filter: { expression: "severity_text='ERROR' and attempt=1" },
}),
);
expect(store.list('logs')).toHaveLength(1);
});
});
describe('signal partitioning', () => {
it('saves to the right bucket per signal', () => {
store.save(
baseInput({
signal: 'logs',
filter: { expression: "severity_text = 'ERROR'" },
}),
);
store.save(
baseInput({
signal: 'traces',
filter: { expression: "service.name = 'orders-api'" },
}),
);
store.save(
baseInput({
signal: 'metrics',
filter: { expression: 'cpu_usage > 80' },
}),
);
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
expect(store.list('metrics')).toHaveLength(1);
expect(store.list('logs')[0].filter.expression).toBe(
"severity_text = 'ERROR'",
);
expect(store.list('traces')[0].filter.expression).toBe(
"service.name = 'orders-api'",
);
expect(store.list('metrics')[0].filter.expression).toBe('cpu_usage > 80');
});
it('does not leak between signals on dedup', () => {
store.save(
baseInput({
signal: 'logs',
filter: { expression: "service.name = 'frontend'" },
}),
);
store.save(
baseInput({
signal: 'traces',
filter: { expression: "service.name = 'frontend'" },
}),
);
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
});
});
describe('LRU cap', () => {
it('caps the bucket at MAX_ENTRIES and evicts the oldest', () => {
const total = MAX_ENTRIES + 1;
for (let i = 0; i < total; i += 1) {
store.save(baseInput({ filter: { expression: `attempt = ${i}` } }));
}
const entries = store.list('logs');
expect(entries).toHaveLength(MAX_ENTRIES);
expect(entries[0].filter.expression).toBe(`attempt = ${total - 1}`);
expect(entries.some((e) => e.filter.expression === 'attempt = 0')).toBe(
false,
);
});
});
describe('remove', () => {
it('removes an entry by id', () => {
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
const saved = saveOrThrow(
baseInput({ filter: { expression: 'http.status_code >= 500' } }),
);
store.remove(saved.id, 'logs');
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe("severity_text = 'ERROR'");
});
it('is a no-op when the id does not exist', () => {
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
store.remove('does-not-exist', 'logs');
expect(store.list('logs')).toHaveLength(1);
});
it('does not touch other signals', () => {
const logsEntry = saveOrThrow(
baseInput({
signal: 'logs',
filter: { expression: "service.name = 'frontend'" },
}),
);
store.save(
baseInput({
signal: 'traces',
filter: { expression: "service.name = 'frontend'" },
}),
);
store.remove(logsEntry.id, 'logs');
expect(store.list('logs')).toHaveLength(0);
expect(store.list('traces')).toHaveLength(1);
});
});
describe('persistence', () => {
it('reads back the same entries after the in-memory state is reset', () => {
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
store.save(baseInput({ filter: { expression: 'http.status_code >= 500' } }));
store.useRecentQueriesStore.setState({ buckets: {} });
const entries = store.list('logs');
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
'http.status_code >= 500',
"severity_text = 'ERROR'",
]);
});
});
describe('reactive subscription via zustand', () => {
it('notifies zustand subscribers on save', () => {
const cb = jest.fn();
const unsubscribe = store.useRecentQueriesStore.subscribe(cb);
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
expect(cb).toHaveBeenCalledTimes(1);
unsubscribe();
});
it('notifies zustand subscribers on remove', () => {
const saved = saveOrThrow(
baseInput({ filter: { expression: "severity_text = 'ERROR'" } }),
);
const cb = jest.fn();
const unsubscribe = store.useRecentQueriesStore.subscribe(cb);
store.remove(saved.id, 'logs');
expect(cb).toHaveBeenCalledTimes(1);
unsubscribe();
});
it('stops notifying after unsubscribe', () => {
const cb = jest.fn();
const unsubscribe = store.useRecentQueriesStore.subscribe(cb);
unsubscribe();
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
expect(cb).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,144 +0,0 @@
import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import type { SignalType } from 'types/api/v5/queryRange';
import { create } from 'zustand';
import { MAX_ENTRIES, STORAGE_VERSION } from './constants';
import { normalizeFilterExpression } from './normalize';
import type { RecentQueriesStoreShape, RecentQueryEntry } from './types';
import { bucketKey, makeId, normalizeSource, storageKeyFor } from './utils';
// Mirrors parsed localStorage so equal raw strings return the same array ref —
// preserves Object.is for zustand selector bail-out.
const persistedBucketCache = new Map<
string,
{ raw: string; parsed: RecentQueryEntry[] }
>();
function loadBucketFromStorage(
signal: SignalType,
source: string,
): RecentQueryEntry[] | null {
const key = bucketKey(signal, source);
try {
const raw = get(storageKeyFor(signal, source));
if (!raw) {
persistedBucketCache.delete(key);
return null;
}
const cached = persistedBucketCache.get(key);
if (cached && cached.raw === raw) {
return cached.parsed;
}
const parsedShape = JSON.parse(raw) as RecentQueriesStoreShape;
if (
parsedShape?.version !== STORAGE_VERSION ||
!Array.isArray(parsedShape.entries)
) {
persistedBucketCache.delete(key);
return null;
}
persistedBucketCache.set(key, { raw, parsed: parsedShape.entries });
return parsedShape.entries;
} catch {
persistedBucketCache.delete(key);
return null;
}
}
function saveBucketToStorage(
signal: SignalType,
source: string,
entries: RecentQueryEntry[],
): void {
try {
const raw = JSON.stringify({ version: STORAGE_VERSION, entries });
if (set(storageKeyFor(signal, source), raw)) {
persistedBucketCache.set(bucketKey(signal, source), {
raw,
parsed: entries,
});
}
} catch {
// Ignore storage errors (e.g. quota exceeded, JSON.stringify failure).
}
}
export type RecentQueryInput = Omit<
RecentQueryEntry,
'id' | 'lastUsedAt' | 'source'
> & {
source?: string;
};
type RecentQueriesState = {
buckets: Record<string, RecentQueryEntry[]>;
save: (entry: RecentQueryInput) => RecentQueryEntry | null;
remove: (id: string, signal: SignalType, source?: string) => void;
};
export const useRecentQueriesStore = create<RecentQueriesState>()(
(set, get) => ({
buckets: {},
save: (entry): RecentQueryEntry | null => {
const normalized = normalizeFilterExpression(entry.filter.expression);
if (!normalized) {
return null;
}
const source = normalizeSource(entry.source);
const key = bucketKey(entry.signal, source);
const current =
get().buckets[key] ?? loadBucketFromStorage(entry.signal, source) ?? [];
const filtered = current.filter(
(e) => normalizeFilterExpression(e.filter.expression) !== normalized,
);
const newEntry: RecentQueryEntry = {
...entry,
source,
id: makeId(entry.signal, source, normalized),
lastUsedAt: Date.now(),
};
const next = [newEntry, ...filtered].slice(0, MAX_ENTRIES);
set({ buckets: { ...get().buckets, [key]: next } });
saveBucketToStorage(entry.signal, source, next);
return newEntry;
},
remove: (id, signal, source = ''): void => {
const key = bucketKey(signal, source);
const current = get().buckets[key] ?? loadBucketFromStorage(signal, source);
if (!current) {
return;
}
const next = current.filter((e) => e.id !== id);
if (next.length === current.length) {
return;
}
set({ buckets: { ...get().buckets, [key]: next } });
saveBucketToStorage(signal, source, next);
},
}),
);
// Plain-function wrappers for non-React callers — same pattern as useColumnStore.ts.
export function save(entry: RecentQueryInput): RecentQueryEntry | null {
return useRecentQueriesStore.getState().save(entry);
}
export function remove(id: string, signal: SignalType, source = ''): void {
useRecentQueriesStore.getState().remove(id, signal, source);
}
// Synchronous bucket read with localStorage fallback for non-React callers.
export function list(signal: SignalType, source = ''): RecentQueryEntry[] {
const key = bucketKey(signal, source);
const state = useRecentQueriesStore.getState();
if (state.buckets[key]) {
return state.buckets[key];
}
return loadBucketFromStorage(signal, source) ?? [];
}

View File

@@ -1,113 +0,0 @@
import { DataSource } from 'types/common/queryBuilder';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { validateQuery } from 'utils/queryValidationUtils';
import * as store from './recentQueriesStore';
import { saveRecentQuery } from './saveRecentQuery';
jest.mock('utils/queryValidationUtils', () => ({
validateQuery: jest.fn(),
}));
const mockedValidateQuery = validateQuery as jest.MockedFunction<
typeof validateQuery
>;
const buildComposite = (
overrides: Partial<IBuilderQuery>[] = [{}],
): { builder: { queryData: IBuilderQuery[] } } => ({
builder: {
queryData: overrides.map((o, i) => ({
queryName: `Q${i}`,
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
aggregateAttribute: undefined as never,
functions: [],
filter: { expression: 'service.name = "frontend"' },
groupBy: [],
expression: `Q${i}`,
disabled: false,
having: [],
limit: null,
stepInterval: null,
orderBy: [],
legend: '',
...o,
})) as IBuilderQuery[],
},
});
describe('saveRecentQuery', () => {
beforeEach(() => {
store.useRecentQueriesStore.setState({ buckets: {} });
localStorage.clear();
mockedValidateQuery.mockReturnValue({
isValid: true,
message: '',
errors: [],
});
});
it('saves the composite query when validation passes', () => {
saveRecentQuery(buildComposite());
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe('service.name = "frontend"');
});
it('does not save when validateQuery rejects the expression', () => {
mockedValidateQuery.mockReturnValue({
isValid: false,
message: 'bad',
errors: [],
});
saveRecentQuery(buildComposite());
expect(store.list('logs')).toHaveLength(0);
});
it('does not save a builder query with an empty filter expression', () => {
saveRecentQuery(buildComposite([{ filter: { expression: '' } }]));
expect(store.list('logs')).toHaveLength(0);
});
it('saves each builder query in the composite separately', () => {
saveRecentQuery(
buildComposite([
{
dataSource: DataSource.LOGS,
filter: { expression: "service.name = 'frontend'" },
},
{
dataSource: DataSource.TRACES,
filter: { expression: "service.name = 'orders-api'" },
},
]),
);
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
});
it('skips builder queries whose dataSource is not a supported signal', () => {
saveRecentQuery(
buildComposite([{ dataSource: 'unknown' as IBuilderQuery['dataSource'] }]),
);
expect(store.list('logs')).toHaveLength(0);
expect(store.list('traces')).toHaveLength(0);
expect(store.list('metrics')).toHaveLength(0);
});
it('is a no-op when the composite is null, undefined, or empty', () => {
saveRecentQuery(null);
saveRecentQuery(undefined);
saveRecentQuery({ builder: { queryData: [] } });
saveRecentQuery({});
expect(store.list('logs')).toHaveLength(0);
});
});

View File

@@ -1,52 +0,0 @@
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SignalType } from 'types/api/v5/queryRange';
import { validateQuery } from 'utils/queryValidationUtils';
import * as store from './recentQueriesStore';
function toSignal(dataSource: IBuilderQuery['dataSource']): SignalType | null {
if (
dataSource === 'logs' ||
dataSource === 'traces' ||
dataSource === 'metrics'
) {
return dataSource;
}
return null;
}
type CompositeWithBuilder = {
builder?: { queryData?: IBuilderQuery[] };
};
// Persists each builder query in the composite as a recent entry. Call this
// only from explicit user-driven Run triggers — reacting to stagedQuery or any
// other derived state pollutes recents with navigation/refresh/go-to traffic.
export function saveRecentQuery(
query: CompositeWithBuilder | null | undefined,
): void {
const queryData = query?.builder?.queryData;
if (!Array.isArray(queryData) || queryData.length === 0) {
return;
}
queryData.forEach((q) => {
const expression = q.filter?.expression?.trim();
if (!expression) {
return;
}
const validation = validateQuery(expression);
if (!validation.isValid) {
return;
}
const signal = toSignal(q.dataSource);
if (!signal) {
return;
}
store.save({
signal,
source: q.source ?? '',
filter: q.filter ?? { expression: '' },
});
});
}

View File

@@ -1,14 +0,0 @@
import type { Filter, SignalType } from 'types/api/v5/queryRange';
export interface RecentQueryEntry {
id: string;
signal: SignalType;
source: string;
filter: Filter;
lastUsedAt: number;
}
export interface RecentQueriesStoreShape {
version: 1;
entries: RecentQueryEntry[];
}

View File

@@ -1,24 +0,0 @@
import type { SignalType } from 'types/api/v5/queryRange';
import { STORAGE_KEY_PREFIX } from './constants';
export function normalizeSource(source: string | undefined): string {
return source ?? '';
}
export function bucketKey(signal: SignalType, source: string): string {
return `${signal}:${source}`;
}
export function storageKeyFor(signal: SignalType, source: string): string {
return `${STORAGE_KEY_PREFIX}:${bucketKey(signal, source)}`;
}
// Same (signal, source, normalized filter) ⇒ same id ⇒ upsert.
export function makeId(
signal: SignalType,
source: string,
normalizedFilter: string,
): string {
return `${signal}|${source}|${normalizedFilter}`;
}

View File

@@ -1,53 +0,0 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import DashboardContainer from 'container/DashboardContainer';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ErrorType } from 'types/common';
function DashboardPage(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const [onModal, Content] = Modal.useModal();
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
dashboardId,
{ confirm: onModal.confirm },
);
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
useEffect(() => {
document.title = dashboardTitle || document.title;
}, [dashboardTitle]);
const errorMessage = isError
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
: 'Something went wrong';
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
return <NotFound />;
}
if (isError && errorMessage) {
return <Typography>{errorMessage}</Typography>;
}
if (isLoading) {
return <Spinner tip="Loading.." />;
}
return (
<>
{Content}
<DashboardContainer />
</>
);
}
export default DashboardPage;

View File

@@ -1,15 +1,53 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardPageV2 from 'pages/DashboardPageV2';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import DashboardContainer from 'container/DashboardContainer';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ErrorType } from 'types/common';
import DashboardPage from './DashboardPage';
function DashboardPage(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
// Serves the V2 dashboard detail page when the `use_dashboard_v2` flag is active;
// otherwise the existing V1 page. Lets V2 dark-ship behind the flag without
// changing route definitions.
function DashboardPageEntry(): JSX.Element {
const isDashboardV2 = useIsDashboardV2();
const [onModal, Content] = Modal.useModal();
return isDashboardV2 ? <DashboardPageV2 /> : <DashboardPage />;
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
dashboardId,
{ confirm: onModal.confirm },
);
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
useEffect(() => {
document.title = dashboardTitle || document.title;
}, [dashboardTitle]);
const errorMessage = isError
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
: 'Something went wrong';
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
return <NotFound />;
}
if (isError && errorMessage) {
return <Typography>{errorMessage}</Typography>;
}
if (isLoading) {
return <Spinner tip="Loading.." />;
}
return (
<>
{Content}
<DashboardContainer />
</>
);
}
export default DashboardPageEntry;
export default DashboardPage;

View File

@@ -28,40 +28,43 @@ import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DashboardSettings from '../../DashboardSettings';
import SettingsDrawer from '../SettingsDrawer';
import styles from './DashboardActions.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
import styles from '../DashboardDescription.module.scss';
interface DashboardActionsProps {
title: string;
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
isDashboardLocked: boolean;
editDashboard: boolean;
isAuthor: boolean;
addPanelPermission: boolean;
onAddPanel: () => void;
onLockToggle: () => void;
onOpenRename: () => void;
}
function DashboardActions({
title,
dashboard,
handle,
isDashboardLocked,
editDashboard,
isAuthor,
addPanelPermission,
onAddPanel,
onLockToggle,
onOpenRename,
}: DashboardActionsProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const id = dashboard.id ?? '';
const title = dashboard.spec?.display?.name ?? '';
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
const deleteDashboardMutation = useDeleteDashboard(id);
useEffect(() => {
if (state.error) {
@@ -100,7 +103,7 @@ function DashboardActions({
const menuItems = useMemo<MenuItem[]>(() => {
const editGroup: MenuItem[] = [];
if (canEdit) {
if (!isDashboardLocked && editDashboard) {
editGroup.push({
key: 'rename',
label: 'Rename',
@@ -156,6 +159,7 @@ function DashboardActions({
);
}, [
isDashboardLocked,
editDashboard,
isAuthor,
user.role,
dashboard.createdBy,
@@ -165,60 +169,58 @@ function DashboardActions({
exportJSON,
setCopy,
dashboardDataJSON,
canEdit,
]);
return (
<div className={styles.dashboardActionsContainer}>
<div className={styles.rightSection}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<div className={styles.dashboardActionsSecondary}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<Ellipsis size={14} />}
className={styles.icons}
testId="options"
/>
</DropdownMenuSimple>
{!isDashboardLocked && editDashboard && (
<>
<Button
variant="solid"
color="secondary"
size="icon"
prefix={<Ellipsis size="md" />}
testId="options"
/>
</DropdownMenuSimple>
{canEdit && (
<>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
{!isDashboardLocked && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
New Panel
Configure
</Button>
)}
</div>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
{!isDashboardLocked && addPanelPermission && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
size="md"
>
New Panel
</Button>
)}
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete dashboard"?`}
description={`Are you sure you want to delete this dashboard - "${title}"? This action cannot be undone.`}
title={`Delete dashboard "${title}"?`}
description="This action cannot be undone."
isLoading={deleteDashboardMutation.isLoading}
onConfirm={handleConfirmDelete}
onClose={(): void => setIsDeleteOpen(false)}

View File

@@ -0,0 +1,210 @@
.dashboardDescriptionContainer {
box-shadow: none;
border: none;
background: unset;
color: var(--l2-foreground);
:global(.ant-card-body) {
padding: 0px;
}
.dashboardDetails {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 16px 16px 0px 16px;
align-items: flex-start;
.leftSection {
display: flex;
align-items: center;
gap: 8px;
width: 45%;
height: 40px;
.dashboardImg {
height: 16px;
width: 16px;
}
.dashboardTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.08px;
max-width: 80%;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.clickableTitle {
cursor: pointer;
}
.titleEdit {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
}
.titleInput {
flex: 1;
min-width: 0;
max-width: 70%;
}
.titleEditActionButton {
--button-height: auto;
--button-padding: 4px;
flex-shrink: 0;
}
.titleSaveActionButton {
--button-border-color: var(--text-forest-700);
--button-outlined-foreground: var(--text-forest-700);
}
.publicDashboardIcon {
margin-right: 4px;
}
}
.rightSection {
display: flex;
width: 55%;
justify-content: flex-end;
flex-wrap: wrap;
align-items: center;
gap: 14px;
height: 40px;
.icons {
display: flex;
align-items: center;
width: 32px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.icons:hover {
background-color: unset;
}
}
}
.dashboardTags {
display: flex;
gap: 6px;
padding: 16px 16px 0px 16px;
flex-wrap: wrap;
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
}
.dashboardDescriptionSection {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
}
}
.dashboardSettings {
width: 191px;
height: 302px;
flex-shrink: 0;
:global(.ant-popover-inner) {
padding: 0px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
) !important;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.menuContent {
display: flex;
flex-direction: column;
section {
display: flex;
flex-direction: column;
align-items: start;
button {
display: flex;
width: 100%;
height: unset;
padding: 8px;
align-items: center;
gap: 12px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
border-top: none;
}
}
.section1,
.section2 {
border-bottom: 1px solid var(--l1-border);
}
.deleteDashboard button {
color: var(--bg-cherry-400) !important;
}
}
}
.deleteModal :global(.ant-modal-confirm-body) {
align-items: center;
}

View File

@@ -0,0 +1,32 @@
import { Badge } from '@signozhq/ui/badge';
import { isEmpty } from 'lodash-es';
import styles from '../DashboardDescription.module.scss';
interface DashboardMetaProps {
tags: string[];
description: string;
}
function DashboardMeta({ tags, description }: DashboardMetaProps): JSX.Element {
return (
<>
{tags.length > 0 && (
<div className={styles.dashboardTags}>
{tags.map((tag) => (
<Badge key={tag} className={styles.tag}>
{tag}
</Badge>
))}
</div>
)}
{!isEmpty(description) && (
<section className={styles.dashboardDescriptionSection}>
{description}
</section>
)}
</>
);
}
export default DashboardMeta;

View File

@@ -0,0 +1,116 @@
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from '../DashboardDescription.module.scss';
interface DashboardTitleProps {
title: string;
image: string;
isPublicDashboard: boolean;
isDashboardLocked: boolean;
isEditable: boolean;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
onStartEdit: () => void;
onCommit: () => void;
onCancel: () => void;
}
function DashboardTitle({
title,
image,
isPublicDashboard,
isDashboardLocked,
isEditable,
isEditing,
draft,
onDraftChange,
onStartEdit,
onCommit,
onCancel,
}: DashboardTitleProps): JSX.Element {
const canEdit = isEditable && !isDashboardLocked;
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
onCommit();
} else if (event.key === 'Escape') {
onCancel();
}
};
return (
<div className={styles.leftSection}>
<img src={image} alt="dashboard-img" className={styles.dashboardImg} />
{isEditing ? (
<div className={styles.titleEdit}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.titleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
size="icon"
className={cx(styles.titleEditActionButton, styles.titleSaveActionButton)}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="destructive"
size="icon"
className={styles.titleEditActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title.length > 30 ? title : ''}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.clickableTitle]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} className={styles.publicDashboardIcon} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
);
}
export default DashboardTitle;

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { Card } from 'antd';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
@@ -12,31 +13,34 @@ import type {
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
import DashboardMeta from './DashboardMeta/DashboardMeta';
import DashboardTitle from './DashboardTitle/DashboardTitle';
import { useEditableTitle } from './DashboardTitle/useEditableTitle';
import styles from './DashboardPageToolbar.module.scss';
import styles from './DashboardDescription.module.scss';
interface DashboardPageToolbarProps {
interface DashboardDescriptionProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
refetch: () => void;
}
function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const id = dashboard.id;
const isDashboardLocked = !!dashboard.locked;
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
const title = dashboard.spec?.display?.name ?? '';
const description = dashboard.spec?.display?.description ?? '';
const image = dashboard.image || Base64Icons[0];
const tags = useMemo(
() =>
@@ -47,6 +51,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
);
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const { showErrorModal } = useErrorModal();
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
@@ -54,6 +59,9 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
const addPanelPermission = !isDashboardLocked;
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
const isPublicDashboard = false;
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
if (!id) {
@@ -102,7 +110,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
onSave: onNameSave,
});
const onAddPanel = useCallback((): void => {
const onEmptyWidgetHandler = useCallback((): void => {
void logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
@@ -110,15 +118,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
}, [id, setIsPanelTypeSelectionModalOpen]);
return (
<section className={styles.dashboardPageToolbarContainer}>
<div className={styles.dashboardInfoWithActions}>
<DashboardInfo
<Card className={styles.dashboardDescriptionContainer}>
<DashboardHeader title={title} image={image} />
<section className={styles.dashboardDetails}>
<DashboardTitle
title={title}
image={image}
tags={tags}
description={description}
isPublicDashboard={false}
isPublicDashboard={isPublicDashboard}
isDashboardLocked={isDashboardLocked}
isEditable={editDashboard}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
@@ -127,18 +135,20 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
onCancel={cancel}
/>
<DashboardActions
title={title}
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
editDashboard={editDashboard}
isAuthor={isAuthor}
onAddPanel={onAddPanel}
addPanelPermission={addPanelPermission}
onAddPanel={onEmptyWidgetHandler}
onLockToggle={handleLockDashboardToggle}
onOpenRename={startEdit}
/>
</div>
</section>
</section>
<DashboardMeta tags={tags} description={description} />
</Card>
);
}
export default DashboardPageToolbar;
export default DashboardDescription;

View File

@@ -1,11 +0,0 @@
.dashboardActionsContainer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
}
.dashboardActionsSecondary {
display: flex;
gap: 12px;
}

View File

@@ -1,61 +0,0 @@
.dashboardInfo {
display: flex;
flex-direction: column;
gap: 8px;
width: 40%;
@media (min-width: 1280px) {
width: 30%;
}
}
.dashboardTitleContainer {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.dashboardImage {
flex-shrink: 0;
}
.dashboardTitle {
flex: 1;
min-width: 0;
max-width: fit-content;
color: var(--l1-foreground);
font-size: 18px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboardTitleHover {
cursor: text !important;
}
.dashboardTitleEditor {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
}
.dashboardTitleInput {
flex: 1;
min-width: 0;
}
.dashboardTitleActionButton {
flex-shrink: 0;
}
.dashboardTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

View File

@@ -1,141 +0,0 @@
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import styles from './DashboardInfo.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
interface DashboardInfoProps {
title: string;
image: string;
tags: string[];
description: string;
isPublicDashboard: boolean;
isDashboardLocked: boolean;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
onStartEdit: () => void;
onCommit: () => void;
onCancel: () => void;
}
function DashboardInfo({
title,
image,
tags,
description,
isPublicDashboard,
isDashboardLocked,
isEditing,
draft,
onDraftChange,
onStartEdit,
onCommit,
onCancel,
}: DashboardInfoProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const hasTags = tags.length > 0;
const hasDescription = !isEmpty(description);
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
onCommit();
} else if (event.key === 'Escape') {
onCancel();
}
};
return (
<div className={styles.dashboardInfo}>
<div className={styles.dashboardTitleContainer}>
<img src={image} alt={title} className={styles.dashboardImage} />
{isEditing ? (
<div className={styles.dashboardTitleEditor}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
color="primary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
{hasTags && (
<div className={styles.dashboardTags}>
{tags.map((tag) => (
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
))}
</div>
)}
{hasDescription && (
<Typography.Text color="muted">{description}</Typography.Text>
)}
</div>
);
}
export default DashboardInfo;

View File

@@ -1,20 +0,0 @@
.dashboardPageToolbarContainer {
position: sticky;
top: 0;
z-index: 10;
color: var(--l2-foreground);
background-color: var(--l1-background);
padding: 16px;
box-shadow: 0 2px 2px 0px var(--l2-border);
}
.dashboardPageToolbarSubContainer {
width: 100%;
}
.dashboardInfoWithActions {
display: flex;
align-items: flex-start;
justify-content: space-between;
width: 100%;
}

View File

@@ -1,8 +1,3 @@
.tabsContent {
padding-left: 0 !important;
padding-right: 0 !important;
}
.placeholder {
padding: 24px;
}
@@ -14,10 +9,3 @@
line-height: 1;
padding-top: 4px;
}
// shared "settings card" wrapper, used by the dashboard-info form and cross-panel sync
.settingsCard {
padding: 24px 16px;
border-radius: 3px;
border: 1px solid var(--l2-border);
}

View File

@@ -1,5 +1,6 @@
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
import { Col, Radio, Tooltip } from 'antd';
import { ExternalLink, SolidInfoCircle } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
@@ -12,9 +13,7 @@ import {
import { getAbsoluteUrl } from 'utils/basePath';
import cx from 'classnames';
import SegmentedControl from '../SegmentedControl/SegmentedControl';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './CrossPanelSync.module.scss';
import styles from '../GeneralSettings.module.scss';
interface CrossPanelSyncProps {
dashboardId: string;
@@ -27,15 +26,12 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
useSyncTooltipFilterMode(dashboardId);
return (
<div className={cx(settingsStyles.settingsCard, styles.crossPanelSyncGroup)}>
<Col className={cx(styles.overviewSettings, styles.crossPanelSyncGroup)}>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelsSyncSectionTitle}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<TooltipSimple
side="top"
withPortal={false}
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
@@ -44,7 +40,7 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<Typography.Link
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
@@ -52,14 +48,15 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
>
Learn more
<ExternalLink size={12} />
</Typography.Link>
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
</TooltipSimple>
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
@@ -69,18 +66,19 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<SegmentedControl
testId="cursor-sync-mode"
<Radio.Group
value={cursorSyncMode}
onChange={setCursorSyncMode}
options={[
{ label: 'No Sync', value: DashboardCursorSync.None },
{ label: 'Crosshair', value: DashboardCursorSync.Crosshair },
{ label: 'Tooltip', value: DashboardCursorSync.Tooltip },
]}
/>
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
@@ -92,25 +90,24 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
matching ones highlighted
</Typography.Text>
</div>
<SegmentedControl
testId="sync-tooltip-filter-mode"
<Radio.Group
value={syncTooltipFilterMode}
onChange={(value): void => {
onChange={(e): void => {
void logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: value,
mode: e.target.value,
});
setSyncTooltipFilterMode(value);
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
options={[
{ label: 'All', value: SyncTooltipFilterMode.All },
{ label: 'Filtered', value: SyncTooltipFilterMode.Filtered },
]}
/>
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</div>
</Col>
);
}

View File

@@ -0,0 +1,85 @@
import { Dispatch, SetStateAction } from 'react';
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Select/Input to @signozhq/ui
import { Col, Input, Select, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { Base64Icons } from '../utils';
import styles from '../GeneralSettings.module.scss';
const { Option } = Select;
interface GeneralFormProps {
title: string;
description: string;
image: string;
tags: string[];
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
onImageChange: (value: string) => void;
onTagsChange: Dispatch<SetStateAction<string[]>>;
}
function GeneralForm({
title,
description,
image,
tags,
onTitleChange,
onDescriptionChange,
onImageChange,
onTagsChange,
}: GeneralFormProps): JSX.Element {
return (
<Col className={styles.overviewSettings}>
<Space direction="vertical" className={styles.formSpace}>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
value={image}
onChange={onImageChange}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
/>
</section>
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={description}
className={styles.descriptionTextArea}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={tags} setTags={onTagsChange} />
</div>
</Space>
</Col>
);
}
export default GeneralForm;

View File

@@ -0,0 +1,238 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px 16px;
}
.overviewSettings {
padding: 16px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.formSpace {
width: 100%;
display: flex;
flex-direction: column;
gap: 21px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
display: flex;
align-items: center;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
display: flex;
align-items: center;
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
import { Check, X } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from './UnsavedChangesFooter.module.scss';
import styles from '../GeneralSettings.module.scss';
interface UnsavedChangesFooterProps {
count: number;
@@ -29,13 +29,13 @@ function UnsavedChangesFooter({
{count > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionButtons}>
<div className={styles.footerActionBtns}>
<Button
variant="ghost"
color="secondary"
disabled={isSaving}
prefix={<X size={14} />}
onClick={onDiscard}
className={styles.discardBtn}
>
Discard
</Button>
@@ -47,6 +47,7 @@ function UnsavedChangesFooter({
prefix={<Check size={14} />}
testId="save-dashboard-config"
onClick={onSave}
className={styles.saveBtn}
>
{t('save')}
</Button>

View File

@@ -11,22 +11,22 @@ import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
import GeneralForm from './GeneralForm/GeneralForm';
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
import { Base64Icons, stringsToTags, tagsToStrings } from './utils';
import styles from './Overview.module.scss';
import styles from './GeneralSettings.module.scss';
interface OverviewProps {
interface GeneralSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function Overview({ dashboard }: OverviewProps): JSX.Element {
function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
const id = dashboard.id;
const refetch = useDashboardStore((s) => s.refetch);
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
const title = dashboard.spec?.display?.name ?? '';
const description = dashboard.spec?.display?.description ?? '';
const image = dashboard.image || Base64Icons[0];
const tagsAsStrings = useMemo(
() => tagsToStrings(dashboard.tags ?? []),
@@ -64,7 +64,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
value,
});
if (updatedTitle !== title && updatedTitle !== '') {
if (updatedTitle !== title) {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
@@ -89,6 +89,9 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
]);
const onSaveHandler = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
const ops = buildPatch();
if (ops.length === 0) {
return;
@@ -107,7 +110,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
}, [id, buildPatch, refetch, showErrorModal]);
useEffect(() => {
let numberOfUnsavedChanges = 0;
let n = 0;
const initialValues = [title, description, tagsAsStrings, image];
const updatedValues = [
updatedTitle,
@@ -117,10 +120,10 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) {
numberOfUnsavedChanges += 1;
n += 1;
}
});
setNumberOfUnsavedChanges(numberOfUnsavedChanges);
setNumberOfUnsavedChanges(n);
}, [
description,
image,
@@ -141,7 +144,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
return (
<div className={styles.overviewContent}>
<DashboardInfoForm
<GeneralForm
title={updatedTitle}
description={updatedDescription}
image={updatedImage}
@@ -164,4 +167,4 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
);
}
export default Overview;
export default GeneralSettings;

View File

@@ -1,86 +0,0 @@
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
}
.crossPanelsSyncSectionTitle {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
}
.crossPanelSyncInfoIcon {
cursor: help;
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
max-width: 200px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
}
.crossPanelSyncTooltipDocLink {
color: var(--primary-background);
font-size: 12px;
margin-top: 16px;
vertical-align: middle;
// typography override
--typography-text-display: inline-flex;
align-items: center;
gap: 5px;
}
.crossPanelSyncRow {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px dashed var(--l2-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex: 1 1 80px;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}

View File

@@ -1,80 +0,0 @@
.formSpace {
display: flex;
flex-direction: column;
gap: 20px;
}
.infoItemContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.infoTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
}
.nameIconInput {
display: flex;
gap: 4px;
[data-radix-popper-content-wrapper] {
z-index: 1100 !important;
}
}
.dashboardImageInput {
display: flex;
width: 32px;
min-width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
background: var(--l3-background);
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
svg {
display: none;
}
}
.dashboardImageOptions {
min-width: min-content;
}
.dashboardImageSelectItem {
width: min-content;
span {
vertical-align: middle;
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
}
.descriptionTextArea {
border-radius: 2px;
border: 1px solid var(--l2-border);
}
// the V1 tags input ships borderless; give the field a visible box to match
.tagsField {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
// background: var(--l3-background);
}

View File

@@ -1,101 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { Input } from '@signozhq/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { Base64Icons } from '../utils';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './DashboardInfoForm.module.scss';
interface DashboardInfoFormProps {
title: string;
description: string;
image: string;
tags: string[];
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
onImageChange: (value: string) => void;
onTagsChange: Dispatch<SetStateAction<string[]>>;
}
function DashboardInfoForm({
title,
description,
image,
tags,
onTitleChange,
onDescriptionChange,
onImageChange,
onTagsChange,
}: DashboardInfoFormProps): JSX.Element {
return (
<div className={settingsStyles.settingsCard}>
<div className={styles.formSpace}>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
value={image}
onChange={(value): void => onImageChange(value as string)}
>
<SelectTrigger className={styles.dashboardImageInput} />
<SelectContent
className={styles.dashboardImageOptions}
withPortal={false}
>
{Base64Icons.map((icon) => (
<SelectItem
key={icon}
value={icon}
className={styles.dashboardImageSelectItem}
>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</SelectItem>
))}
</SelectContent>
</Select>
<Input
testId="dashboard-name"
className={styles.dashboardNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
/>
</section>
</div>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Description</Typography>
<AntdInput.TextArea
data-testid="dashboard-desc"
rows={6}
value={description}
className={styles.descriptionTextArea}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Tags</Typography>
<div className={styles.tagsField}>
<AddTags tags={tags} setTags={onTagsChange} />
</div>
</div>
</div>
</div>
);
}
export default DashboardInfoForm;

View File

@@ -1,5 +0,0 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 24px;
}

View File

@@ -1,61 +0,0 @@
.segmented {
// override RadioGroup's default vertical grid; lay segments out connected
display: inline-flex;
grid-auto-flow: column;
gap: 0;
flex-shrink: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
}
.segment {
position: relative;
display: flex;
align-items: center;
&:not(:last-child) {
border-right: 1px solid var(--l2-border);
}
// the visible segment is the radio's label (htmlFor-wired, so clicks register)
label {
display: flex;
align-items: center;
min-height: 24px;
padding: 6px 14px;
font-family: Inter;
font-size: 13px;
line-height: 20px;
color: var(--l2-foreground);
white-space: nowrap;
cursor: pointer;
user-select: none;
}
}
// collapse the radio circle into a transparent full-cell click target
.segmentInput {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
// hide the default radio dot/indicator
* {
display: none;
}
}
// highlight the selected segment as a raised, lighter pill (data-state is a
// stable Radix attribute). --l3-background is the lightest layer, so lift it
// further with a subtle foreground tint rather than going darker.
.segmentInput[data-state='checked'] + label {
background: var(--l3-background);
color: var(--l1-foreground);
font-weight: 500;
}

View File

@@ -1,51 +0,0 @@
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import styles from './SegmentedControl.module.scss';
export interface SegmentedControlOption<T extends string> {
label: string;
value: T;
}
interface SegmentedControlProps<T extends string> {
value: T;
options: SegmentedControlOption<T>[];
onChange: (value: T) => void;
testId?: string;
}
/**
* Connected pill segmented control composed on top of @signozhq/ui RadioGroup:
* the radio circle is collapsed into a transparent full-cell click target and
* the label becomes the visible segment (highlighted via the radio's stable
* `data-state="checked"`). Keeps radio semantics + keyboard nav.
*/
function SegmentedControl<T extends string>({
value,
options,
onChange,
testId,
}: SegmentedControlProps<T>): JSX.Element {
return (
<RadioGroup
className={styles.segmented}
value={value}
onChange={(next): void => onChange(next as T)}
testId={testId}
>
{options.map((option) => (
<RadioGroupItem
key={option.value}
value={option.value}
containerClassName={styles.segment}
className={styles.segmentInput}
testId={testId ? `${testId}-${option.value}` : undefined}
>
{option.label}
</RadioGroupItem>
))}
</RadioGroup>
);
}
export default SegmentedControl;

Some files were not shown because too many files have changed in this diff Show More