mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-17 22:10:34 +01:00
Compare commits
11 Commits
e2e/alert_
...
feat/llm-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7ca2ff589 | ||
|
|
056d8d673e | ||
|
|
a19c8f3cc6 | ||
|
|
b1aaf3ec9a | ||
|
|
58b55c922d | ||
|
|
629ea3b8be | ||
|
|
287b60cbe6 | ||
|
|
e206625e5f | ||
|
|
4f3b7647d3 | ||
|
|
76ab0f25c0 | ||
|
|
59501ce4a7 |
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -19,5 +19,8 @@
|
||||
"editor.defaultFormatter": "vscode.html-language-features"
|
||||
},
|
||||
"python-envs.defaultEnvManager": "ms-python.python:system",
|
||||
"python-envs.pythonProjects": []
|
||||
"python-envs.pythonProjects": [],
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3566,10 +3566,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/ErrorsResponseerroradditional'
|
||||
type: array
|
||||
invalidReferences:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
message:
|
||||
type: string
|
||||
retry:
|
||||
@@ -3590,6 +3586,10 @@ components:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
suggestions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
ErrorsResponseretryjson:
|
||||
properties:
|
||||
@@ -9004,10 +9004,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -9160,10 +9156,6 @@ paths:
|
||||
$ref: '#/components/schemas/DashboardtypesUpdatablePublicDashboard'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -9758,10 +9750,6 @@ paths:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
@@ -10946,10 +10934,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11063,10 +11047,6 @@ paths:
|
||||
$ref: '#/components/schemas/AuthtypesPatchableRole'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11213,10 +11193,6 @@ paths:
|
||||
$ref: '#/components/schemas/CoretypesPatchableObjects'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -11666,10 +11642,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11777,10 +11749,6 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -11962,10 +11930,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -12023,10 +11987,6 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableFactorAPIKey'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -12209,10 +12169,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -12288,10 +12244,6 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"404":
|
||||
content:
|
||||
@@ -12783,6 +12735,53 @@ paths:
|
||||
summary: Update a span mapper
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/stats:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the collected stats for the organization
|
||||
operationId: GetStats
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get stats
|
||||
tags:
|
||||
- stats
|
||||
/api/v1/testChannel:
|
||||
post:
|
||||
deprecated: true
|
||||
@@ -13469,10 +13468,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -13732,10 +13727,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -13788,10 +13779,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -15522,10 +15509,6 @@ paths:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesUpdateMetricMetadataRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
@@ -20824,10 +20807,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -20875,10 +20854,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
|
||||
@@ -109,6 +109,20 @@ 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:
|
||||
@@ -387,3 +401,4 @@ 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.
|
||||
|
||||
@@ -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 := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
|
||||
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to decode request body: %v", err))
|
||||
if err := binding.JSON.BindBody(req.Body, &queryRangeRequest); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -323,3 +323,10 @@ export const AIAssistantPage = Loadable(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IntegrationsDetailsPage,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -507,6 +508,13 @@ 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 = {
|
||||
|
||||
@@ -63,7 +63,7 @@ export const deletePublicDashboard = (
|
||||
{ id }: DeletePublicDashboardPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/dashboards/${id}/public`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -346,7 +346,7 @@ export const updatePublicDashboard = (
|
||||
dashboardtypesUpdatablePublicDashboardDTO?: BodyType<DashboardtypesUpdatablePublicDashboardDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/dashboards/${id}/public`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -836,7 +836,7 @@ export const deleteDashboardV2 = (
|
||||
{ id }: DeleteDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1214,7 +1214,7 @@ export const unlockDashboardV2 = (
|
||||
{ id }: UnlockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1293,7 +1293,7 @@ export const lockDashboardV2 = (
|
||||
{ id }: LockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
@@ -1471,7 +1471,7 @@ export const unpinDashboardV2 = (
|
||||
{ id }: UnpinDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/users/me/dashboards/${id}/pins`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1550,7 +1550,7 @@ export const pinDashboardV2 = (
|
||||
{ id }: PinDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/users/me/dashboards/${id}/pins`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const handleExportRawDataPOST = (
|
||||
params?: HandleExportRawDataPOSTParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/export_raw_data`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -680,7 +680,7 @@ export const updateMetricMetadata = (
|
||||
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/metrics/${metricName}/metadata`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -203,7 +203,7 @@ export const deleteRole = (
|
||||
{ id }: DeleteRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -372,7 +372,7 @@ export const patchRole = (
|
||||
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
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<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -222,7 +222,7 @@ export const deleteServiceAccount = (
|
||||
{ id }: DeleteServiceAccountPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_accounts/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -405,7 +405,7 @@ export const updateServiceAccount = (
|
||||
serviceaccounttypesPostableServiceAccountDTO?: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
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<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
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<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
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<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
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<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_accounts/me`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -2143,6 +2143,10 @@ export interface ErrorsResponseerroradditionalDTO {
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
export interface ErrorsResponseretryjsonDTO {
|
||||
@@ -2158,10 +2162,6 @@ export interface ErrorsJSONDTO {
|
||||
* @type array
|
||||
*/
|
||||
errors?: ErrorsResponseerroradditionalDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
invalidReferences?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9736,6 +9736,19 @@ 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;
|
||||
};
|
||||
|
||||
96
frontend/src/api/generated/services/stats/index.ts
Normal file
96
frontend/src/api/generated/services/stats/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ! 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;
|
||||
};
|
||||
@@ -91,6 +91,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
@@ -0,0 +1,172 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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;
|
||||
@@ -0,0 +1,388 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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;
|
||||
@@ -0,0 +1,179 @@
|
||||
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;
|
||||
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
@@ -0,0 +1,110 @@
|
||||
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;
|
||||
@@ -0,0 +1,194 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
55
frontend/src/container/LLMObservabilityModelPricing/types.ts
Normal file
55
frontend/src/container/LLMObservabilityModelPricing/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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 };
|
||||
}
|
||||
162
frontend/src/container/LLMObservabilityModelPricing/utils.ts
Normal file
162
frontend/src/container/LLMObservabilityModelPricing/utils.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
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 };
|
||||
};
|
||||
@@ -206,6 +206,7 @@ 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];
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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;
|
||||
@@ -1,116 +0,0 @@
|
||||
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;
|
||||
@@ -0,0 +1,11 @@
|
||||
.dashboardActionsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboardActionsSecondary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -28,43 +28,40 @@ import { USER_ROLES } from 'types/roles';
|
||||
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import SettingsDrawer from '../SettingsDrawer';
|
||||
import styles from '../DashboardDescription.module.scss';
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
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(id);
|
||||
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
@@ -103,7 +100,7 @@ function DashboardActions({
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const editGroup: MenuItem[] = [];
|
||||
if (!isDashboardLocked && editDashboard) {
|
||||
if (canEdit) {
|
||||
editGroup.push({
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
@@ -159,7 +156,6 @@ function DashboardActions({
|
||||
);
|
||||
}, [
|
||||
isDashboardLocked,
|
||||
editDashboard,
|
||||
isAuthor,
|
||||
user.role,
|
||||
dashboard.createdBy,
|
||||
@@ -169,58 +165,60 @@ function DashboardActions({
|
||||
exportJSON,
|
||||
setCopy,
|
||||
dashboardDataJSON,
|
||||
canEdit,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.rightSection}>
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Ellipsis size={14} />}
|
||||
className={styles.icons}
|
||||
testId="options"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<>
|
||||
<div className={styles.dashboardActionsSecondary}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
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"
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
New Panel
|
||||
</Button>
|
||||
<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>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
title={`Delete dashboard "${title}"?`}
|
||||
description="This action cannot be undone."
|
||||
title={`Delete dashboard"?`}
|
||||
description={`Are you sure you want to delete this dashboard - "${title}"? This action cannot be undone.`}
|
||||
isLoading={deleteDashboardMutation.isLoading}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onClose={(): void => setIsDeleteOpen(false)}
|
||||
@@ -0,0 +1,61 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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;
|
||||
@@ -0,0 +1,20 @@
|
||||
.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%;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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 {
|
||||
@@ -13,34 +12,31 @@ 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 DashboardMeta from './DashboardMeta/DashboardMeta';
|
||||
import DashboardTitle from './DashboardTitle/DashboardTitle';
|
||||
import { useEditableTitle } from './DashboardTitle/useEditableTitle';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
|
||||
|
||||
import styles from './DashboardDescription.module.scss';
|
||||
import styles from './DashboardPageToolbar.module.scss';
|
||||
|
||||
interface DashboardDescriptionProps {
|
||||
interface DashboardPageToolbarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
function DashboardPageToolbar(props: DashboardPageToolbarProps): 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(
|
||||
() =>
|
||||
@@ -51,7 +47,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
@@ -59,9 +54,6 @@ function DashboardDescription(props: DashboardDescriptionProps): 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) {
|
||||
@@ -110,7 +102,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
onSave: onNameSave,
|
||||
});
|
||||
|
||||
const onEmptyWidgetHandler = useCallback((): void => {
|
||||
const onAddPanel = useCallback((): void => {
|
||||
void logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
@@ -118,15 +110,15 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
}, [id, setIsPanelTypeSelectionModalOpen]);
|
||||
|
||||
return (
|
||||
<Card className={styles.dashboardDescriptionContainer}>
|
||||
<DashboardHeader title={title} image={image} />
|
||||
<section className={styles.dashboardDetails}>
|
||||
<DashboardTitle
|
||||
<section className={styles.dashboardPageToolbarContainer}>
|
||||
<div className={styles.dashboardInfoWithActions}>
|
||||
<DashboardInfo
|
||||
title={title}
|
||||
image={image}
|
||||
isPublicDashboard={isPublicDashboard}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={false}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isEditable={editDashboard}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
@@ -135,20 +127,18 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
onCancel={cancel}
|
||||
/>
|
||||
<DashboardActions
|
||||
title={title}
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
editDashboard={editDashboard}
|
||||
isAuthor={isAuthor}
|
||||
addPanelPermission={addPanelPermission}
|
||||
onAddPanel={onEmptyWidgetHandler}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={handleLockDashboardToggle}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</section>
|
||||
<DashboardMeta tags={tags} description={description} />
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardDescription;
|
||||
export default DashboardPageToolbar;
|
||||
@@ -1,3 +1,8 @@
|
||||
.tabsContent {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -9,3 +14,10 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
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;
|
||||
@@ -1,238 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// 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';
|
||||
@@ -13,7 +12,9 @@ import {
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from '../GeneralSettings.module.scss';
|
||||
import SegmentedControl from '../SegmentedControl/SegmentedControl';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './CrossPanelSync.module.scss';
|
||||
|
||||
interface CrossPanelSyncProps {
|
||||
dashboardId: string;
|
||||
@@ -26,12 +27,15 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
return (
|
||||
<Col className={cx(styles.overviewSettings, styles.crossPanelSyncGroup)}>
|
||||
<div className={cx(settingsStyles.settingsCard, styles.crossPanelSyncGroup)}>
|
||||
<div className={styles.crossPanelSyncSectionHeader}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
<Typography.Text className={styles.crossPanelsSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
withPortal={false}
|
||||
title={
|
||||
<div className={styles.crossPanelSyncTooltipContent}>
|
||||
<strong className={styles.crossPanelSyncTooltipTitle}>
|
||||
@@ -40,7 +44,7 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
<span className={styles.crossPanelSyncTooltipDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</span>
|
||||
<a
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -48,15 +52,14 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</Typography.Link>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
|
||||
</Tooltip>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
@@ -66,19 +69,18 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
<SegmentedControl
|
||||
testId="cursor-sync-mode"
|
||||
value={cursorSyncMode}
|
||||
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>
|
||||
onChange={setCursorSyncMode}
|
||||
options={[
|
||||
{ label: 'No Sync', value: DashboardCursorSync.None },
|
||||
{ label: 'Crosshair', value: DashboardCursorSync.Crosshair },
|
||||
{ label: 'Tooltip', value: DashboardCursorSync.Tooltip },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
@@ -90,24 +92,25 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
|
||||
<SegmentedControl
|
||||
testId="sync-tooltip-filter-mode"
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(e): void => {
|
||||
onChange={(value): void => {
|
||||
void logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: e.target.value,
|
||||
mode: value,
|
||||
});
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
setSyncTooltipFilterMode(value);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
options={[
|
||||
{ label: 'All', value: SyncTooltipFilterMode.All },
|
||||
{ label: 'Filtered', value: SyncTooltipFilterMode.Filtered },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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;
|
||||
@@ -0,0 +1,5 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
@@ -0,0 +1,39 @@
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 16px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
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-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionButtons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -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 '../GeneralSettings.module.scss';
|
||||
import styles from './UnsavedChangesFooter.module.scss';
|
||||
|
||||
interface UnsavedChangesFooterProps {
|
||||
count: number;
|
||||
@@ -29,13 +29,13 @@ function UnsavedChangesFooter({
|
||||
{count > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.footerActionBtns}>
|
||||
<div className={styles.footerActionButtons}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
disabled={isSaving}
|
||||
prefix={<X size={14} />}
|
||||
onClick={onDiscard}
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
@@ -47,7 +47,6 @@ function UnsavedChangesFooter({
|
||||
prefix={<Check size={14} />}
|
||||
testId="save-dashboard-config"
|
||||
onClick={onSave}
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
@@ -11,22 +11,22 @@ import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
|
||||
import GeneralForm from './GeneralForm/GeneralForm';
|
||||
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
|
||||
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
|
||||
import { Base64Icons, stringsToTags, tagsToStrings } from './utils';
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
interface GeneralSettingsProps {
|
||||
interface OverviewProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
|
||||
function Overview({ dashboard }: OverviewProps): 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 GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
|
||||
value,
|
||||
});
|
||||
|
||||
if (updatedTitle !== title) {
|
||||
if (updatedTitle !== title && updatedTitle !== '') {
|
||||
ops.push(replace('/spec/display/name', updatedTitle));
|
||||
}
|
||||
if (updatedDescription !== description) {
|
||||
@@ -89,9 +89,6 @@ function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const ops = buildPatch();
|
||||
if (ops.length === 0) {
|
||||
return;
|
||||
@@ -110,7 +107,7 @@ function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
|
||||
}, [id, buildPatch, refetch, showErrorModal]);
|
||||
|
||||
useEffect(() => {
|
||||
let n = 0;
|
||||
let numberOfUnsavedChanges = 0;
|
||||
const initialValues = [title, description, tagsAsStrings, image];
|
||||
const updatedValues = [
|
||||
updatedTitle,
|
||||
@@ -120,10 +117,10 @@ function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
|
||||
];
|
||||
initialValues.forEach((val, index) => {
|
||||
if (!isEqual(val, updatedValues[index])) {
|
||||
n += 1;
|
||||
numberOfUnsavedChanges += 1;
|
||||
}
|
||||
});
|
||||
setNumberOfUnsavedChanges(n);
|
||||
setNumberOfUnsavedChanges(numberOfUnsavedChanges);
|
||||
}, [
|
||||
description,
|
||||
image,
|
||||
@@ -144,7 +141,7 @@ function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<GeneralForm
|
||||
<DashboardInfoForm
|
||||
title={updatedTitle}
|
||||
description={updatedDescription}
|
||||
image={updatedImage}
|
||||
@@ -167,4 +164,4 @@ function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneralSettings;
|
||||
export default Overview;
|
||||
@@ -0,0 +1,106 @@
|
||||
// settings card wrapper — mirrors the V1 public dashboard treatment
|
||||
.publicDashboardCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.statusTitle {
|
||||
margin-bottom: 16px;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeRangeSelectGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeRangeSelectLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.timeRangeSelect {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.urlGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.urlLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.urlContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.urlText {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.callout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 12px 8px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
|
||||
}
|
||||
|
||||
.calloutIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-robin-300);
|
||||
}
|
||||
|
||||
.calloutText {
|
||||
color: var(--text-robin-300);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Globe, Trash } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
interface PublicDashboardActionsProps {
|
||||
isPublic: boolean;
|
||||
disabled: boolean;
|
||||
isPublishing: boolean;
|
||||
isUpdating: boolean;
|
||||
isUnpublishing: boolean;
|
||||
onPublish: () => void;
|
||||
onUpdate: () => void;
|
||||
onUnpublish: () => void;
|
||||
}
|
||||
|
||||
function PublicDashboardActions({
|
||||
isPublic,
|
||||
disabled,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
isUnpublishing,
|
||||
onPublish,
|
||||
onUpdate,
|
||||
onUnpublish,
|
||||
}: PublicDashboardActionsProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
{isPublic ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
disabled={disabled}
|
||||
loading={isUnpublishing}
|
||||
prefix={<Trash size={14} />}
|
||||
testId="public-dashboard-unpublish"
|
||||
onClick={onUnpublish}
|
||||
>
|
||||
Unpublish dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isUpdating}
|
||||
prefix={<Globe size={14} />}
|
||||
testId="public-dashboard-update"
|
||||
onClick={onUpdate}
|
||||
>
|
||||
Update published dashboard
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isPublishing}
|
||||
prefix={<Globe size={14} />}
|
||||
testId="public-dashboard-publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
Publish dashboard
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardActions;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
function PublicDashboardCallout(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.callout}>
|
||||
<Info size={12} className={styles.calloutIcon} />
|
||||
<Typography.Text className={styles.calloutText}>
|
||||
Dashboard variables won't work in public dashboards
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardCallout;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { TIME_RANGE_PRESETS_OPTIONS } from './constants';
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
interface PublicDashboardSettingsFormProps {
|
||||
timeRangeEnabled: boolean;
|
||||
defaultTimeRange: string;
|
||||
disabled: boolean;
|
||||
onTimeRangeEnabledChange: (value: boolean) => void;
|
||||
onDefaultTimeRangeChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function PublicDashboardSettingsForm({
|
||||
timeRangeEnabled,
|
||||
defaultTimeRange,
|
||||
disabled,
|
||||
onTimeRangeEnabledChange,
|
||||
onDefaultTimeRangeChange,
|
||||
}: PublicDashboardSettingsFormProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
id="public-dashboard-enable-time-range"
|
||||
className={styles.checkbox}
|
||||
testId="public-dashboard-time-range-toggle"
|
||||
value={timeRangeEnabled}
|
||||
disabled={disabled}
|
||||
onChange={(checked): void => onTimeRangeEnabledChange(checked === true)}
|
||||
>
|
||||
Enable time range
|
||||
</Checkbox>
|
||||
|
||||
<div className={styles.timeRangeSelectGroup}>
|
||||
<Typography.Text className={styles.timeRangeSelectLabel}>
|
||||
Default time range
|
||||
</Typography.Text>
|
||||
<SelectSimple
|
||||
className={styles.timeRangeSelect}
|
||||
testId="public-dashboard-default-time-range"
|
||||
placeholder="Select default time range"
|
||||
items={TIME_RANGE_PRESETS_OPTIONS}
|
||||
value={defaultTimeRange}
|
||||
disabled={disabled}
|
||||
onChange={(value): void => onDefaultTimeRangeChange(value as string)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardSettingsForm;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
interface PublicDashboardStatusProps {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
function PublicDashboardStatus({
|
||||
isPublic,
|
||||
}: PublicDashboardStatusProps): JSX.Element {
|
||||
return (
|
||||
<Typography.Text className={styles.statusTitle}>
|
||||
{isPublic
|
||||
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
|
||||
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardStatus;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Copy, ExternalLink } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
interface PublicDashboardUrlProps {
|
||||
url: string;
|
||||
onCopy: () => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function PublicDashboardUrl({
|
||||
url,
|
||||
onCopy,
|
||||
onOpen,
|
||||
}: PublicDashboardUrlProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.urlGroup}>
|
||||
<Typography.Text className={styles.urlLabel}>
|
||||
Public dashboard URL
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.urlContainer}>
|
||||
<Typography.Text className={styles.urlText}>{url}</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Copy public dashboard URL"
|
||||
testId="public-dashboard-copy-url"
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open public dashboard in new tab"
|
||||
testId="public-dashboard-open-url"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardUrl;
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface TimeRangePresetOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// Default time-range presets offered for the public dashboard viewer.
|
||||
export const TIME_RANGE_PRESETS_OPTIONS: TimeRangePresetOption[] = [
|
||||
{ label: 'Last 5 minutes', value: '5m' },
|
||||
{ label: 'Last 15 minutes', value: '15m' },
|
||||
{ label: 'Last 30 minutes', value: '30m' },
|
||||
{ label: 'Last 1 hour', value: '1h' },
|
||||
{ label: 'Last 6 hours', value: '6h' },
|
||||
{ label: 'Last 1 day', value: '24h' },
|
||||
];
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import PublicDashboardActions from './PublicDashboardActions';
|
||||
import PublicDashboardCallout from './PublicDashboardCallout';
|
||||
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm';
|
||||
import PublicDashboardStatus from './PublicDashboardStatus';
|
||||
import PublicDashboardUrl from './PublicDashboardUrl';
|
||||
import { usePublicDashboard } from './usePublicDashboard';
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
interface PublicDashboardSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function PublicDashboardSettings({
|
||||
dashboard,
|
||||
}: PublicDashboardSettingsProps): JSX.Element {
|
||||
const {
|
||||
isPublic,
|
||||
isAdmin,
|
||||
isLoading,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
isUnpublishing,
|
||||
timeRangeEnabled,
|
||||
defaultTimeRange,
|
||||
publicUrl,
|
||||
setTimeRangeEnabled,
|
||||
setDefaultTimeRange,
|
||||
onPublish,
|
||||
onUpdate,
|
||||
onUnpublish,
|
||||
onCopyUrl,
|
||||
onOpenUrl,
|
||||
} = usePublicDashboard(dashboard.id);
|
||||
|
||||
const controlsDisabled = isLoading || !isAdmin;
|
||||
|
||||
return (
|
||||
<div className={styles.publicDashboardCard}>
|
||||
<PublicDashboardStatus isPublic={isPublic} />
|
||||
|
||||
<PublicDashboardSettingsForm
|
||||
timeRangeEnabled={timeRangeEnabled}
|
||||
defaultTimeRange={defaultTimeRange}
|
||||
disabled={controlsDisabled}
|
||||
onTimeRangeEnabledChange={setTimeRangeEnabled}
|
||||
onDefaultTimeRangeChange={setDefaultTimeRange}
|
||||
/>
|
||||
|
||||
{isPublic && (
|
||||
<PublicDashboardUrl url={publicUrl} onCopy={onCopyUrl} onOpen={onOpenUrl} />
|
||||
)}
|
||||
|
||||
<PublicDashboardCallout />
|
||||
|
||||
<PublicDashboardActions
|
||||
isPublic={isPublic}
|
||||
disabled={controlsDisabled}
|
||||
isPublishing={isPublishing}
|
||||
isUpdating={isUpdating}
|
||||
isUnpublishing={isUnpublishing}
|
||||
onPublish={onPublish}
|
||||
onUpdate={onUpdate}
|
||||
onUnpublish={onUnpublish}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardSettings;
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateGetPublicDashboard,
|
||||
useCreatePublicDashboard,
|
||||
useDeletePublicDashboard,
|
||||
useGetPublicDashboard,
|
||||
useUpdatePublicDashboard,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
export interface UsePublicDashboardReturn {
|
||||
isPublic: boolean;
|
||||
isAdmin: boolean;
|
||||
isLoading: boolean;
|
||||
isPublishing: boolean;
|
||||
isUpdating: boolean;
|
||||
isUnpublishing: boolean;
|
||||
timeRangeEnabled: boolean;
|
||||
defaultTimeRange: string;
|
||||
publicUrl: string;
|
||||
setTimeRangeEnabled: (value: boolean) => void;
|
||||
setDefaultTimeRange: (value: string) => void;
|
||||
onPublish: () => void;
|
||||
onUpdate: () => void;
|
||||
onUnpublish: () => void;
|
||||
onCopyUrl: () => void;
|
||||
onOpenUrl: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the public-dashboard query, the create/update/delete mutations and the
|
||||
* local form state for the V2 publish settings section. Targets the same
|
||||
* `/dashboards/{id}/public` endpoint as V1 via the generated client.
|
||||
*/
|
||||
export function usePublicDashboard(
|
||||
dashboardId: string,
|
||||
): UsePublicDashboardReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { user } = useAppContext();
|
||||
const isAdmin = user?.role === USER_ROLES.ADMIN;
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const [timeRangeEnabled, setTimeRangeEnabled] = useState<boolean>(true);
|
||||
const [defaultTimeRange, setDefaultTimeRange] =
|
||||
useState<string>(DEFAULT_TIME_RANGE);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingMeta,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetPublicDashboard(
|
||||
{ id: dashboardId },
|
||||
{ query: { enabled: !!dashboardId, retry: false } },
|
||||
);
|
||||
|
||||
// react-query retains the last successful `data` even after a refetch errors, so
|
||||
// after unpublishing (the refetch 404s) `data` still holds the old publicPath.
|
||||
// Gate on `!error` so the UI flips back to the private state.
|
||||
const publicMeta = error ? undefined : data?.data;
|
||||
const isPublic = !!publicMeta?.publicPath;
|
||||
|
||||
// Seed form state from the server config when published.
|
||||
useEffect(() => {
|
||||
if (publicMeta) {
|
||||
setTimeRangeEnabled(publicMeta.timeRangeEnabled ?? false);
|
||||
setDefaultTimeRange(publicMeta.defaultTimeRange || DEFAULT_TIME_RANGE);
|
||||
}
|
||||
}, [publicMeta]);
|
||||
|
||||
// A 404 (dashboard not published) surfaces as an error — reset to defaults.
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setTimeRangeEnabled(true);
|
||||
setDefaultTimeRange(DEFAULT_TIME_RANGE);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const publicUrl = useMemo(
|
||||
() => getAbsoluteUrl(publicMeta?.publicPath ?? ''),
|
||||
[publicMeta?.publicPath],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(err: unknown): void => {
|
||||
showErrorModal(err as APIError);
|
||||
},
|
||||
[showErrorModal],
|
||||
);
|
||||
|
||||
const handleSuccess = useCallback(
|
||||
(message: string): void => {
|
||||
toast.success(message);
|
||||
void invalidateGetPublicDashboard(queryClient, { id: dashboardId });
|
||||
void refetch();
|
||||
},
|
||||
[queryClient, dashboardId, refetch],
|
||||
);
|
||||
|
||||
const { mutate: createPublicDashboard, isLoading: isPublishing } =
|
||||
useCreatePublicDashboard({
|
||||
mutation: {
|
||||
onSuccess: () => handleSuccess('Dashboard published successfully'),
|
||||
onError: handleError,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: updatePublicDashboard, isLoading: isUpdating } =
|
||||
useUpdatePublicDashboard({
|
||||
mutation: {
|
||||
onSuccess: () => handleSuccess('Public dashboard updated successfully'),
|
||||
onError: handleError,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: deletePublicDashboard, isLoading: isUnpublishing } =
|
||||
useDeletePublicDashboard({
|
||||
mutation: {
|
||||
onSuccess: () => handleSuccess('Dashboard unpublished successfully'),
|
||||
onError: handleError,
|
||||
},
|
||||
});
|
||||
|
||||
const onPublish = useCallback((): void => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
createPublicDashboard({
|
||||
pathParams: { id: dashboardId },
|
||||
data: { timeRangeEnabled, defaultTimeRange },
|
||||
});
|
||||
}, [createPublicDashboard, dashboardId, timeRangeEnabled, defaultTimeRange]);
|
||||
|
||||
const onUpdate = useCallback((): void => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
updatePublicDashboard({
|
||||
pathParams: { id: dashboardId },
|
||||
data: { timeRangeEnabled, defaultTimeRange },
|
||||
});
|
||||
}, [updatePublicDashboard, dashboardId, timeRangeEnabled, defaultTimeRange]);
|
||||
|
||||
const onUnpublish = useCallback((): void => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
deletePublicDashboard({ pathParams: { id: dashboardId } });
|
||||
}, [deletePublicDashboard, dashboardId]);
|
||||
|
||||
const onCopyUrl = useCallback((): void => {
|
||||
if (!publicUrl) {
|
||||
return;
|
||||
}
|
||||
copyToClipboard(publicUrl);
|
||||
toast.success('Copied public dashboard URL successfully');
|
||||
}, [copyToClipboard, publicUrl]);
|
||||
|
||||
const onOpenUrl = useCallback((): void => {
|
||||
if (publicUrl) {
|
||||
openInNewTab(publicUrl);
|
||||
}
|
||||
}, [publicUrl]);
|
||||
|
||||
const isLoading =
|
||||
isLoadingMeta || isFetching || isPublishing || isUpdating || isUnpublishing;
|
||||
|
||||
return {
|
||||
isPublic,
|
||||
isAdmin,
|
||||
isLoading,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
isUnpublishing,
|
||||
timeRangeEnabled,
|
||||
defaultTimeRange,
|
||||
publicUrl,
|
||||
setTimeRangeEnabled,
|
||||
setDefaultTimeRange,
|
||||
onPublish,
|
||||
onUpdate,
|
||||
onUnpublish,
|
||||
onCopyUrl,
|
||||
onOpenUrl,
|
||||
};
|
||||
}
|
||||
@@ -1,45 +1,91 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
import { TabItemProps, Tabs } from '@signozhq/ui/tabs';
|
||||
import {
|
||||
TabItemProps,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import GeneralSettings from './General';
|
||||
import { SettingsTabPlaceholder } from './utils';
|
||||
import Overview from './Overview';
|
||||
import PublicDashboardSettings from './PublicDashboard';
|
||||
import VariablesSettings from './Variables';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
|
||||
interface DashboardSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
enum TabKeys {
|
||||
OVERVIEW = 'Overview',
|
||||
VARIABLES = 'Variables',
|
||||
PUBLISH = 'Publish',
|
||||
}
|
||||
|
||||
const prefixIcons: Record<TabKeys, JSX.Element> = {
|
||||
[TabKeys.OVERVIEW]: <Table size={14} />,
|
||||
[TabKeys.VARIABLES]: <Braces size={14} />,
|
||||
[TabKeys.PUBLISH]: <Globe size={14} />,
|
||||
};
|
||||
|
||||
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
|
||||
|
||||
const items: TabItemProps[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
children: <GeneralSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Table size={14} />,
|
||||
key: TabKeys.OVERVIEW,
|
||||
label: TabKeys.OVERVIEW,
|
||||
children: <Overview dashboard={dashboard} />,
|
||||
},
|
||||
{
|
||||
key: 'variables',
|
||||
label: 'Variables',
|
||||
key: TabKeys.VARIABLES,
|
||||
label: TabKeys.VARIABLES,
|
||||
children: <VariablesSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Braces size={14} />,
|
||||
},
|
||||
{
|
||||
key: 'public-dashboard',
|
||||
label: 'Publish',
|
||||
children: (
|
||||
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
prefixIcon: <Globe size={14} />,
|
||||
},
|
||||
...(enablePublicDashboard
|
||||
? [
|
||||
{
|
||||
key: TabKeys.PUBLISH,
|
||||
label: TabKeys.PUBLISH,
|
||||
children: <PublicDashboardSettings dashboard={dashboard} />,
|
||||
disabled: user?.role !== USER_ROLES.ADMIN,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[dashboard],
|
||||
[enablePublicDashboard, dashboard, user?.role],
|
||||
);
|
||||
|
||||
return <Tabs defaultValue="general" items={items} />;
|
||||
return (
|
||||
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
|
||||
<TabsList variant="primary">
|
||||
{Object.values(TabKeys).map((key) => (
|
||||
<TabsTrigger value={key} key={key}>
|
||||
{prefixIcons[key]}
|
||||
{key}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{items.map((item) => (
|
||||
<TabsContent value={item.key} key={item.key} className={styles.tabsContent}>
|
||||
{item.children}
|
||||
</TabsContent>
|
||||
))}
|
||||
</TabsRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardSettings;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Empty } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
|
||||
/**
|
||||
* TEMPORARY: stand-in for the not-yet-built Variables / Publish settings tabs.
|
||||
* Will be cleaned up later once those tabs ship their real content.
|
||||
*/
|
||||
export function SettingsTabPlaceholder({
|
||||
message,
|
||||
}: {
|
||||
message: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={styles.placeholder}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<Typography.Text>{message}</Typography.Text>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
.dashboardBreadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
padding-left: 8px;
|
||||
|
||||
.linkToPreviousPage {
|
||||
// Collapse the design-system Button's fixed-height/padding box so it hugs
|
||||
// the label like inline text (the breadcrumb is text, not a chunky button).
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-gap: 4px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.currentPage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0px 2px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.currentPage:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
|
||||
.dashboardIconImage {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import getSessionStorageApi from 'api/browser/sessionstorage/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import styles from './DashboardBreadcrumbs.module.scss';
|
||||
|
||||
interface DashboardBreadcrumbsProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardBreadcrumbs({
|
||||
title,
|
||||
image,
|
||||
}: DashboardBreadcrumbsProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const goToListPage = useCallback(() => {
|
||||
const dashboardsListQueryParamsString = getSessionStorageApi(
|
||||
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (dashboardsListQueryParamsString) {
|
||||
safeNavigate({
|
||||
pathname: ROUTES.ALL_DASHBOARD,
|
||||
search: `?${dashboardsListQueryParamsString}`,
|
||||
});
|
||||
} else {
|
||||
safeNavigate(ROUTES.ALL_DASHBOARD);
|
||||
}
|
||||
}, [safeNavigate]);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardBreadcrumbs}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<LayoutGrid size={14} />}
|
||||
onClick={goToListPage}
|
||||
className={styles.linkToPreviousPage}
|
||||
testId="dashboard-breadcrumb-list"
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
<div>/</div>
|
||||
<div className={styles.currentPage}>
|
||||
<img
|
||||
src={image}
|
||||
alt="dashboard-icon"
|
||||
className={styles.dashboardIconImage}
|
||||
/>
|
||||
<Typography.Text>{title}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardBreadcrumbs;
|
||||
@@ -1,9 +0,0 @@
|
||||
.dashboardHeader {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
|
||||
|
||||
import styles from './DashboardHeader.module.scss';
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardHeader({ title, image }: DashboardHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.dashboardHeader}>
|
||||
<DashboardBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardHeader);
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
import getSessionStorageApi from 'api/browser/sessionstorage/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from '@signozhq/ui/breadcrumb';
|
||||
|
||||
interface DashboardPageBreadcrumbsProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardPageBreadcrumbs({
|
||||
title,
|
||||
image,
|
||||
}: DashboardPageBreadcrumbsProps): JSX.Element {
|
||||
const dashboardPageLink = useMemo(() => {
|
||||
const dashboardsListQueryParamsString = getSessionStorageApi(
|
||||
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
|
||||
);
|
||||
|
||||
return dashboardsListQueryParamsString
|
||||
? `${ROUTES.ALL_DASHBOARD}?${dashboardsListQueryParamsString}`
|
||||
: ROUTES.ALL_DASHBOARD;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink icon={<LayoutGrid size={14} />} href={dashboardPageLink}>
|
||||
Dashboard
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink icon={<img src={image} alt="dashboard-icon" />}>
|
||||
{title}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageBreadcrumbs;
|
||||
@@ -0,0 +1,9 @@
|
||||
.dashboardPageHeader {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-left: 14px;
|
||||
height: 48px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
|
||||
|
||||
import styles from './DashboardPageHeader.module.scss';
|
||||
|
||||
interface DashboardPageHeaderProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardPageHeader({
|
||||
title,
|
||||
image,
|
||||
}: DashboardPageHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.dashboardPageHeader}>
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardPageHeader);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -6,10 +6,12 @@ import PanelTypeSelectionModal from 'container/DashboardContainer/PanelTypeSelec
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import DashboardDescription from './DashboardDescription';
|
||||
import DashboardPageToolbar from './DashboardPageToolbar';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
|
||||
import { Base64Icons } from './DashboardSettings/Overview/utils';
|
||||
|
||||
interface DashboardContainerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -20,32 +22,49 @@ function DashboardContainer({
|
||||
dashboard,
|
||||
refetch,
|
||||
}: DashboardContainerProps): JSX.Element {
|
||||
useEffect(() => {
|
||||
document.title = dashboard.name;
|
||||
}, [dashboard.name]);
|
||||
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const isEditable = !dashboard.locked && editDashboard;
|
||||
const [editDashboardPermission] = useComponentPermission(
|
||||
['edit_dashboard'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
// Publish edit context to the store so hooks/components read it from there
|
||||
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
|
||||
const setEditContext = useDashboardStore((s) => s.setEditContext);
|
||||
useEffect(() => {
|
||||
setEditContext({ dashboardId: dashboard.id ?? '', isEditable, refetch });
|
||||
}, [dashboard.id, isEditable, refetch, setEditContext]);
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isEditable: !dashboard.locked && editDashboardPermission,
|
||||
refetch,
|
||||
});
|
||||
}, [
|
||||
dashboard.id,
|
||||
dashboard.locked,
|
||||
editDashboardPermission,
|
||||
refetch,
|
||||
setEditContext,
|
||||
]);
|
||||
|
||||
const { spec } = dashboard;
|
||||
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
|
||||
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);
|
||||
const spec = dashboard.spec;
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
const name = spec.display.name;
|
||||
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
<DashboardDescription
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
<DashboardPageToolbar
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
|
||||
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
|
||||
</div>
|
||||
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
|
||||
trigger; navigates to the widget editor route on selection. */}
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { GridItem } from './utils';
|
||||
* intentionally side-effect-free (no React, no network) so they can be unit
|
||||
* tested and reused by the layout hooks. JSON pointers target the postable
|
||||
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
|
||||
* patches in DashboardSettings/General and DashboardDescription).
|
||||
* patches in DashboardSettings/Overview and DashboardDescription).
|
||||
*/
|
||||
|
||||
const { add, replace, remove } = DashboardtypesPatchOpDTO;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -16,13 +15,6 @@ function DashboardPageV2(): JSX.Element {
|
||||
});
|
||||
|
||||
const dashboard = data?.data;
|
||||
const name = dashboard?.spec?.display?.name;
|
||||
|
||||
useEffect(() => {
|
||||
if (name) {
|
||||
document.title = name;
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import LLMObservabilityModelPricing from 'container/LLMObservabilityModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
function LLMObservabilityModelPricingPage(): JSX.Element {
|
||||
return <LLMObservabilityModelPricing />;
|
||||
}
|
||||
|
||||
export default LLMObservabilityModelPricingPage;
|
||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, RenderOptions, RenderResult } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
@@ -301,7 +302,7 @@ export function AllTheProviders({
|
||||
<ErrorModalProvider>
|
||||
<TimezoneProvider>
|
||||
<PreferenceContextProvider>
|
||||
{queryBuilderContent}
|
||||
<TooltipProvider>{queryBuilderContent}</TooltipProvider>
|
||||
</PreferenceContextProvider>
|
||||
</TimezoneProvider>
|
||||
</ErrorModalProvider>
|
||||
|
||||
@@ -20,7 +20,8 @@ export type ComponentTypes =
|
||||
| 'add_panel'
|
||||
| 'page_pipelines'
|
||||
| 'edit_locked_dashboard'
|
||||
| 'add_panel_locked_dashboard';
|
||||
| 'add_panel_locked_dashboard'
|
||||
| 'manage_llm_pricing';
|
||||
|
||||
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
current_org_settings: ['ADMIN'],
|
||||
@@ -42,6 +43,7 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
page_pipelines: ['ADMIN', 'EDITOR'],
|
||||
edit_locked_dashboard: ['ADMIN', 'AUTHOR'],
|
||||
add_panel_locked_dashboard: ['ADMIN', 'AUTHOR'],
|
||||
manage_llm_pricing: ['ADMIN', 'EDITOR', 'AUTHOR'],
|
||||
};
|
||||
|
||||
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
@@ -136,4 +138,5 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
@@ -70,6 +71,7 @@ type provider struct {
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -102,6 +104,7 @@ func NewFactory(
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
@@ -137,6 +140,7 @@ func NewFactory(
|
||||
llmPricingRuleHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -174,6 +178,7 @@ func newProvider(
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -210,6 +215,7 @@ func newProvider(
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
}
|
||||
|
||||
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
|
||||
@@ -334,6 +340,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addStatsReporterRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
33
pkg/apiserver/signozapiserver/statsreporter.go
Normal file
33
pkg/apiserver/signozapiserver/statsreporter.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addStatsReporterRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/stats", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.statsHandler.Get),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetStats",
|
||||
Tags: []string{"stats"},
|
||||
Summary: "Get stats",
|
||||
Description: "This endpoint returns the collected stats for the organization",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: map[string]any{},
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -21,18 +21,25 @@ type base struct {
|
||||
e error
|
||||
// u denotes the url for the documentation (if present) for the error.
|
||||
u string
|
||||
// a denotes any additional error messages (if present).
|
||||
a []string
|
||||
// a denotes any additional error details (if present). Each detail carries an
|
||||
// optional message and any user-facing suggestions closely related to it.
|
||||
a []additional
|
||||
// s contains the stacktrace captured at error creation time.
|
||||
s fmt.Stringer
|
||||
// r is the retry strategy for the error, if applicable.
|
||||
r *retry
|
||||
// suggestions is a list of user-facing suggestions related to the error, if present.
|
||||
// For example, narrow the time range window or typo suggestion
|
||||
// suggestions is a list of user-facing suggestions related to the error as a
|
||||
// whole (not tied to a specific detail in a), if present. For example,
|
||||
// "narrow the time range window". For a suggestion tied to a specific detail,
|
||||
// use the suggestions field on additional instead.
|
||||
suggestions []string
|
||||
}
|
||||
|
||||
// additional is a single supplementary error detail: a message plus any
|
||||
// user-facing suggestions (e.g. "did you mean: `x`") closely related to it.
|
||||
type additional struct {
|
||||
message string
|
||||
suggestions []string
|
||||
// invalidReferences is a list of references that were invalid and contributed to the error, if present.
|
||||
// For example, a typo from user avg(sum), we return invalidRefences: ['sum']
|
||||
invalidReferences []string
|
||||
}
|
||||
|
||||
// Stacktrace returns the stacktrace captured at error creation time, formatted as a string.
|
||||
@@ -47,16 +54,15 @@ func (b *base) Stacktrace() string {
|
||||
// and returns a new base error.
|
||||
func (b *base) WithStacktrace(s string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: rawStacktrace(s),
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
invalidReferences: b.invalidReferences,
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: rawStacktrace(s),
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +83,7 @@ func New(t typ, code Code, message string) *base {
|
||||
m: message,
|
||||
e: nil,
|
||||
u: "",
|
||||
a: []string{},
|
||||
a: []additional{},
|
||||
s: newStackTrace(),
|
||||
}
|
||||
}
|
||||
@@ -96,127 +102,144 @@ func Newf(t typ, code Code, format string, args ...any) *base {
|
||||
// Wrapf returns a new error by formatting the error message with the supplied format specifier
|
||||
// and wrapping another error with base.
|
||||
func Wrapf(cause error, t typ, code Code, format string, args ...any) *base {
|
||||
return &base{
|
||||
b := &base{
|
||||
t: t,
|
||||
c: code,
|
||||
m: fmt.Sprintf(format, args...),
|
||||
e: cause,
|
||||
s: newStackTrace(),
|
||||
}
|
||||
|
||||
// Carry the user-facing hints forward from the wrapped cause. Otherwise
|
||||
// wrapping a structured error (e.g. one returned from an UnmarshalJSON) would
|
||||
// silently drop its suggestions / invalid references from the response.
|
||||
// Propagation is transitive: each Wrapf copies from its immediate cause, so
|
||||
// the hints survive arbitrarily deep wrapping as long as it goes through Wrapf.
|
||||
if inner, ok := cause.(*base); ok {
|
||||
b.r = inner.r
|
||||
b.a = inner.a
|
||||
b.suggestions = inner.suggestions
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// Wrap returns a new error by wrapping another error with base.
|
||||
func Wrap(cause error, t typ, code Code, message string) *base {
|
||||
return &base{
|
||||
b := &base{
|
||||
t: t,
|
||||
c: code,
|
||||
m: message,
|
||||
e: cause,
|
||||
s: newStackTrace(),
|
||||
}
|
||||
|
||||
// Carry the user-facing hints forward from the wrapped cause. Otherwise
|
||||
// wrapping a structured error (e.g. one returned from an UnmarshalJSON) would
|
||||
// silently drop its suggestions / invalid references from the response.
|
||||
// Propagation is transitive: each Wrapf copies from its immediate cause, so
|
||||
// the hints survive arbitrarily deep wrapping as long as it goes through Wrapf.
|
||||
if inner, ok := cause.(*base); ok {
|
||||
b.r = inner.r
|
||||
b.a = inner.a
|
||||
b.suggestions = inner.suggestions
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// WithAdditionalf adds an additional error message to the existing error.
|
||||
func WithAdditionalf(cause error, format string, args ...any) *base {
|
||||
t, c, m, e, u, a := Unwrapb(cause)
|
||||
var s fmt.Stringer
|
||||
if original, ok := cause.(*base); ok {
|
||||
s = original.s
|
||||
}
|
||||
b := &base{
|
||||
t: t,
|
||||
c: c,
|
||||
m: m,
|
||||
e: e,
|
||||
u: u,
|
||||
a: a,
|
||||
s: s,
|
||||
r: retryOf(cause),
|
||||
suggestions: suggestionsOf(cause),
|
||||
invalidReferences: invalidReferencesOf(cause),
|
||||
if b, ok := cause.(*base); ok {
|
||||
return b.WithAdditional(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
return b.WithAdditional(append(a, fmt.Sprintf(format, args...))...)
|
||||
t, c, m, e, u, a := Unwrapb(cause)
|
||||
b := &base{t: t, c: c, m: m, e: e, u: u, a: a, s: newStackTrace(), r: retryOf(cause)}
|
||||
return b.WithAdditional(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// WithSuggestiveAdditionalf appends a detail whose message is built from the format
|
||||
// specifier and which carries the given user-facing suggestions closely related to
|
||||
// it, returning a new base error.
|
||||
func WithSuggestiveAdditionalf(cause error, suggestions []string, format string, args ...any) *base {
|
||||
if b, ok := cause.(*base); ok {
|
||||
return b.WithSuggestiveAdditional(fmt.Sprintf(format, args...), suggestions...)
|
||||
}
|
||||
|
||||
t, c, m, e, u, a := Unwrapb(cause)
|
||||
b := &base{t: t, c: c, m: m, e: e, u: u, a: a, s: newStackTrace(), r: retryOf(cause)}
|
||||
return b.WithSuggestiveAdditional(fmt.Sprintf(format, args...), suggestions...)
|
||||
}
|
||||
|
||||
// WithUrl adds a url to the base error and returns a new base error.
|
||||
func (b *base) WithUrl(u string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
invalidReferences: b.invalidReferences,
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
}
|
||||
}
|
||||
|
||||
// WithAdditional adds additional messages to the base error and returns a new base error.
|
||||
func (b *base) WithAdditional(a ...string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
invalidReferences: b.invalidReferences,
|
||||
// WithAdditional appends one or more message-only details and returns a new base error.
|
||||
func (b *base) WithAdditional(messages ...string) *base {
|
||||
extra := make([]additional, len(messages))
|
||||
for i, m := range messages {
|
||||
extra[i] = additional{message: m}
|
||||
}
|
||||
return b.WithAdditionals(extra...)
|
||||
}
|
||||
|
||||
// WithAdditionals appends the given details and returns a new base error. It is also
|
||||
// the way to re-attach details previously pulled out via Unwrapb.
|
||||
func (b *base) WithAdditionals(additionals ...additional) *base {
|
||||
nb := *b
|
||||
nb.a = append(append([]additional{}, b.a...), additionals...)
|
||||
return &nb
|
||||
}
|
||||
|
||||
// withRetry adds retry metadata to the base error and returns a new base error.
|
||||
func (b *base) withRetry(r retry) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: &r,
|
||||
suggestions: b.suggestions,
|
||||
invalidReferences: b.invalidReferences,
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: &r,
|
||||
suggestions: b.suggestions,
|
||||
}
|
||||
}
|
||||
|
||||
// WithSuggestions replaces the list of suggestions on the base error.
|
||||
// WithSuggestions replaces the error-wide suggestions and returns a new base error.
|
||||
// These relate to the error as a whole; for a suggestion tied to a specific detail,
|
||||
// use WithSuggestiveAdditional.
|
||||
func (b *base) WithSuggestions(suggestions ...string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: suggestions,
|
||||
invalidReferences: b.invalidReferences,
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: suggestions,
|
||||
}
|
||||
}
|
||||
|
||||
// WithInvalidReferences replaces the list of invalid references on the base error.
|
||||
func (b *base) WithInvalidReferences(invalidReferences ...string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
invalidReferences: invalidReferences,
|
||||
}
|
||||
// WithSuggestiveAdditional appends a detail carrying a message together with the
|
||||
// user-facing suggestions closely related to it, and returns a new base error.
|
||||
func (b *base) WithSuggestiveAdditional(message string, suggestions ...string) *base {
|
||||
return b.WithAdditionals(additional{message: message, suggestions: suggestions})
|
||||
}
|
||||
|
||||
// WithRetryAfter sets the retry delay on the base error and returns a new base error.
|
||||
@@ -231,13 +254,13 @@ func (b *base) WithRetryAfter(delay time.Duration) *base {
|
||||
// and the error itself.
|
||||
//
|
||||
//nolint:staticcheck // ST1008: intentional return order matching struct field order (TCMEUA)
|
||||
func Unwrapb(cause error) (typ, Code, string, error, string, []string) {
|
||||
func Unwrapb(cause error) (typ, Code, string, error, string, []additional) {
|
||||
base, ok := cause.(*base)
|
||||
if ok {
|
||||
return base.t, base.c, base.m, base.e, base.u, base.a
|
||||
}
|
||||
|
||||
return TypeInternal, CodeUnknown, cause.Error(), cause, "", []string{}
|
||||
return TypeInternal, CodeUnknown, cause.Error(), cause, "", []additional{}
|
||||
}
|
||||
|
||||
// Ast checks if the provided error matches the specified custom error type.
|
||||
@@ -371,11 +394,3 @@ func suggestionsOf(err error) []string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidReferencesOf(err error) []string {
|
||||
base, ok := err.(*base)
|
||||
if ok {
|
||||
return base.invalidReferences
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestUnwrapb(t *testing.T) {
|
||||
assert.Equal(t, "this is a base err", amessage)
|
||||
assert.Equal(t, oerr, aerr)
|
||||
assert.Equal(t, "https://docs", au)
|
||||
assert.Equal(t, []string{"additional err"}, aa)
|
||||
assert.Equal(t, []additional{{message: "additional err"}}, aa)
|
||||
|
||||
atyp, _, _, _, _, _ = Unwrapb(oerr)
|
||||
assert.Equal(t, TypeInternal, atyp)
|
||||
@@ -74,6 +74,19 @@ func TestWithSuggestions(t *testing.T) {
|
||||
assert.Equal(t, []string{"first", "second"}, suggestionsOf(err))
|
||||
}
|
||||
|
||||
func TestWithSuggestiveAdditional(t *testing.T) {
|
||||
// WithSuggestiveAdditional attaches suggestions to a specific detail (in the
|
||||
// errors array), distinct from the error-wide WithSuggestions.
|
||||
err := NewInvalidInputf(MustNewCode("bad_field"), "unknown field %q", "filed").
|
||||
WithSuggestiveAdditional("field `filed` not found", "did you mean: `field`")
|
||||
|
||||
j := AsJSON(err)
|
||||
assert.Equal(t, []responseerroradditional{
|
||||
{Message: "field `filed` not found", Suggestions: []string{"did you mean: `field`"}},
|
||||
}, j.Errors)
|
||||
assert.Nil(t, j.Suggestions, "detail-scoped suggestions must not leak into the error-wide list")
|
||||
}
|
||||
|
||||
func TestWithRetryAfter(t *testing.T) {
|
||||
err := New(TypeInternal, MustNewCode("test_code"), "test error").WithRetryAfter(5 * time.Microsecond)
|
||||
r := retryOf(err)
|
||||
@@ -81,24 +94,11 @@ func TestWithRetryAfter(t *testing.T) {
|
||||
assert.Equal(t, 5, int(r.delay.Microseconds()))
|
||||
}
|
||||
|
||||
func TestWithInvalidReferences(t *testing.T) {
|
||||
// WithInvalidReferences populates the list.
|
||||
err := New(TypeInvalidInput, MustNewCode("bad_ref"), "bad ref").
|
||||
WithInvalidReferences("queries[0]", "queries[1]")
|
||||
assert.Equal(t, []string{"queries[0]", "queries[1]"}, invalidReferencesOf(err))
|
||||
|
||||
// WithInvalidReferences replaces the entire list on each call.
|
||||
err = err.WithInvalidReferences("queries[2]")
|
||||
assert.Equal(t, []string{"queries[2]"}, invalidReferencesOf(err),
|
||||
"WithInvalidReferences must replace the entire list")
|
||||
}
|
||||
|
||||
func TestAsJSONBaseError(t *testing.T) {
|
||||
err := New(TypeInvalidInput, MustNewCode("bad_input"), "field foo is bad").
|
||||
WithUrl("https://docs/bad_input").
|
||||
WithAdditional("hint1", "hint2").
|
||||
WithSuggestions("try this").
|
||||
WithInvalidReferences("queries[0]")
|
||||
WithSuggestions("try this")
|
||||
|
||||
j := AsJSON(err)
|
||||
|
||||
@@ -113,7 +113,20 @@ func TestAsJSONBaseError(t *testing.T) {
|
||||
assert.Nil(t, j.Retry, "bare New(...) should not populate a retry block")
|
||||
|
||||
assert.Equal(t, []string{"try this"}, j.Suggestions)
|
||||
assert.Equal(t, []string{"queries[0]"}, j.InvalidReferences)
|
||||
}
|
||||
|
||||
func TestAsJSONWrappedErrorPreservesHints(t *testing.T) {
|
||||
// An inner base carries the user-facing hints (e.g. produced inside an
|
||||
// UnmarshalJSON), then gets re-wrapped (e.g. WrapInvalidInputf). suggestionsOf
|
||||
// must walk the cause chain so the hints still surface.
|
||||
inner := NewInvalidInputf(MustNewCode("bad_kind"), "unknown panel kind %q", "boom").
|
||||
WithSuggestions("valid references: a, b, c")
|
||||
|
||||
wrapped := WrapInvalidInputf(inner, MustNewCode("outer"), "%s", inner.Error())
|
||||
|
||||
j := AsJSON(wrapped)
|
||||
assert.Equal(t, []string{"valid references: a, b, c"}, j.Suggestions,
|
||||
"suggestions on an inner base must survive wrapping")
|
||||
}
|
||||
|
||||
func TestAsJSONRetryBlock(t *testing.T) {
|
||||
@@ -147,7 +160,6 @@ func TestAsJSONRetryBlock(t *testing.T) {
|
||||
func TestAsJSONOptionalFieldsOmittedWhenEmpty(t *testing.T) {
|
||||
j := AsJSON(New(TypeInternal, MustNewCode("boom"), "boom"))
|
||||
assert.Nil(t, j.Suggestions, "no suggestions set => Suggestions must be nil so json omitempty drops it")
|
||||
assert.Nil(t, j.InvalidReferences, "no invalid references set => InvalidReferences must be nil so json omitempty drops it")
|
||||
}
|
||||
|
||||
func TestWithStacktrace(t *testing.T) {
|
||||
|
||||
@@ -7,14 +7,13 @@ import (
|
||||
)
|
||||
|
||||
type JSON struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Code string `json:"code" required:"true"`
|
||||
Message string `json:"message" required:"true"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Errors []responseerroradditional `json:"errors,omitempty"`
|
||||
Retry *responseretryjson `json:"retry,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
InvalidReferences []string `json:"invalidReferences,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Code string `json:"code" required:"true"`
|
||||
Message string `json:"message" required:"true"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Errors []responseerroradditional `json:"errors,omitempty"`
|
||||
Retry *responseretryjson `json:"retry,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
}
|
||||
|
||||
type responseretryjson struct {
|
||||
@@ -22,7 +21,8 @@ type responseretryjson struct {
|
||||
}
|
||||
|
||||
type responseerroradditional struct {
|
||||
Message string `json:"message"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
}
|
||||
|
||||
func AsJSON(cause error) *JSON {
|
||||
@@ -31,7 +31,7 @@ func AsJSON(cause error) *JSON {
|
||||
|
||||
rea := make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{v}
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
}
|
||||
|
||||
var retry *responseretryjson
|
||||
@@ -40,14 +40,13 @@ func AsJSON(cause error) *JSON {
|
||||
}
|
||||
|
||||
return &JSON{
|
||||
Type: t.String(),
|
||||
Code: c.String(),
|
||||
Message: m,
|
||||
Url: u,
|
||||
Errors: rea,
|
||||
Retry: retry,
|
||||
Suggestions: suggestionsOf(cause),
|
||||
InvalidReferences: invalidReferencesOf(cause),
|
||||
Type: t.String(),
|
||||
Code: c.String(),
|
||||
Message: m,
|
||||
Url: u,
|
||||
Errors: rea,
|
||||
Retry: retry,
|
||||
Suggestions: suggestionsOf(cause),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +56,7 @@ func AsURLValues(cause error) url.Values {
|
||||
|
||||
rea := make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{v}
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
}
|
||||
|
||||
errors, err := json.Marshal(rea)
|
||||
|
||||
165
pkg/errors/suggestions.go
Normal file
165
pkg/errors/suggestions.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
typoSuggestionThreshold = 0.75
|
||||
// maxValidReferences caps how many valid references are listed so
|
||||
// high-cardinality sets (e.g. telemetry field keys) don't dump the entire
|
||||
// set into the error.
|
||||
maxValidReferences = 20
|
||||
)
|
||||
|
||||
// SuggestionsOnLevenshteinDistance returns a "did you mean" correction (only
|
||||
// when a close match at least typoSuggestionThreshold similar exists) followed
|
||||
// by the valid-references list.
|
||||
func SuggestionsOnLevenshteinDistance(invalidInput string, validInputs []string) []string {
|
||||
suggestions := make([]string, 0, 2)
|
||||
|
||||
if match, ok := ClosestLevenshteinMatch(invalidInput, validInputs); ok {
|
||||
suggestions = append(suggestions, didYouMean(match))
|
||||
}
|
||||
|
||||
if refs := ValidReferences(validInputs...); refs != "" {
|
||||
suggestions = append(suggestions, refs)
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// ClosestLevenshteinMatch returns the candidate most similar to input that is at least
|
||||
// typoSuggestionThreshold similar, or false when nothing is close enough.
|
||||
func ClosestLevenshteinMatch(input string, candidates []string) (string, bool) {
|
||||
var bestMatch string
|
||||
bestSimilarity := 0.0
|
||||
|
||||
for _, candidate := range candidates {
|
||||
sim := similarity(input, candidate)
|
||||
|
||||
if sim > bestSimilarity && sim >= typoSuggestionThreshold {
|
||||
bestSimilarity = sim
|
||||
bestMatch = candidate
|
||||
}
|
||||
}
|
||||
|
||||
if bestSimilarity >= typoSuggestionThreshold {
|
||||
return bestMatch, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SuggestionsFromFunc formats the string produce returns as a one-element
|
||||
// "did you mean: `x`" slice, or nil when it returns the empty string (so callers
|
||||
// with their own matching strategy compose into a suggestions list cleanly).
|
||||
func SuggestionsFromFunc(produce func() string) []string {
|
||||
s := produce()
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{didYouMean(s)}
|
||||
}
|
||||
|
||||
// ValidReferences formats values as "valid references: `a`, `b`", capped at
|
||||
// maxValidReferences with a "(+N more)" suffix. Each value is rendered as its
|
||||
// own string, an Enum() element's StringValue(), or fmt.Sprint as a fallback.
|
||||
// It returns "" when there are no values, so callers don't surface a bare
|
||||
// "valid references: " with nothing after it.
|
||||
func ValidReferences[T any](values ...T) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
refs := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
switch t := any(v).(type) {
|
||||
case string:
|
||||
refs = append(refs, t)
|
||||
case interface{ StringValue() string }:
|
||||
refs = append(refs, t.StringValue())
|
||||
default:
|
||||
refs = append(refs, fmt.Sprint(t))
|
||||
}
|
||||
}
|
||||
|
||||
truncated := 0
|
||||
if len(refs) > maxValidReferences {
|
||||
truncated = len(refs) - maxValidReferences
|
||||
refs = refs[:maxValidReferences]
|
||||
}
|
||||
|
||||
quoted := make([]string, len(refs))
|
||||
for i, r := range refs {
|
||||
quoted[i] = "`" + r + "`"
|
||||
}
|
||||
|
||||
out := "valid references: " + strings.Join(quoted, ", ")
|
||||
if truncated > 0 {
|
||||
out += fmt.Sprintf(" (+%d more)", truncated)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
s1 = strings.ToLower(s1)
|
||||
s2 = strings.ToLower(s2)
|
||||
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
v0 := make([]int, len(s2)+1)
|
||||
v1 := make([]int, len(s2)+1)
|
||||
|
||||
for i := 0; i <= len(s2); i++ {
|
||||
v0[i] = i
|
||||
}
|
||||
|
||||
for i := range len(s1) {
|
||||
v1[0] = i + 1
|
||||
|
||||
for j := range len(s2) {
|
||||
deletionCost := v0[j+1] + 1
|
||||
insertionCost := v1[j] + 1
|
||||
|
||||
var substitutionCost int
|
||||
if s1[i] == s2[j] {
|
||||
substitutionCost = v0[j]
|
||||
} else {
|
||||
substitutionCost = v0[j] + 1
|
||||
}
|
||||
|
||||
v1[j+1] = min(deletionCost, insertionCost, substitutionCost)
|
||||
}
|
||||
|
||||
for j := 0; j <= len(s2); j++ {
|
||||
v0[j] = v1[j]
|
||||
}
|
||||
}
|
||||
|
||||
return v1[len(s2)]
|
||||
}
|
||||
|
||||
func similarity(s1, s2 string) float64 {
|
||||
maxLen := max(len(s1), len(s2))
|
||||
if maxLen == 0 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
distance := levenshteinDistance(s1, s2)
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
// didYouMean formats a correction as "did you mean: `x`".
|
||||
func didYouMean(match string) string {
|
||||
return "did you mean: `" + match + "`"
|
||||
}
|
||||
31
pkg/errors/suggestions_test.go
Normal file
31
pkg/errors/suggestions_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidReferences(t *testing.T) {
|
||||
// An empty set returns "" so callers don't surface a bare "valid references: ".
|
||||
assert.Equal(t, "", ValidReferences[string]())
|
||||
|
||||
assert.Equal(t, "valid references: `a`, `b`", ValidReferences("a", "b"))
|
||||
}
|
||||
|
||||
func TestSuggestionsOnLevenshteinDistance(t *testing.T) {
|
||||
// No valid inputs => no suggestions at all (no bare "valid references: ").
|
||||
assert.Empty(t, SuggestionsOnLevenshteinDistance("foo", nil))
|
||||
|
||||
// Close match => did-you-mean plus the valid-references list.
|
||||
assert.Equal(t,
|
||||
[]string{"did you mean: `name`", "valid references: `name`, `color`"},
|
||||
SuggestionsOnLevenshteinDistance("nam", []string{"name", "color"}),
|
||||
)
|
||||
|
||||
// No close match => valid-references list only.
|
||||
assert.Equal(t,
|
||||
[]string{"valid references: `name`, `color`"},
|
||||
SuggestionsOnLevenshteinDistance("zzzzz", []string{"name", "color"}),
|
||||
)
|
||||
}
|
||||
@@ -35,11 +35,7 @@ func (handler *handler) GetFeatures(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ var (
|
||||
type bindBodyOptions struct {
|
||||
DisallowUnknownFields bool
|
||||
UseNumber bool
|
||||
UnknownFieldContext string
|
||||
}
|
||||
|
||||
type BindBodyOption func(*bindBodyOptions)
|
||||
@@ -30,6 +31,12 @@ func WithDisallowUnknownFields(disallowUnknownFields bool) BindBodyOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithUnknownFieldContext(context string) BindBodyOption {
|
||||
return func(options *bindBodyOptions) {
|
||||
options.UnknownFieldContext = context
|
||||
}
|
||||
}
|
||||
|
||||
func WithUseNumber(useNumber bool) BindBodyOption {
|
||||
return func(options *bindBodyOptions) {
|
||||
options.UseNumber = useNumber
|
||||
|
||||
@@ -3,6 +3,8 @@ package binding
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
@@ -59,8 +61,70 @@ func (b *jsonBinding) BindBody(body io.Reader, obj any, opts ...BindBodyOption)
|
||||
WithAdditional("value of type '" + unmarshalTypeError.Value + "' was received, try sending '" + unmarshalTypeError.Type.String() + "' instead?")
|
||||
}
|
||||
|
||||
// DisallowUnknownFields surfaces a bare `json: unknown field "x"`; turn it
|
||||
// into a structured invalid-input error with did-you-mean/valid-reference
|
||||
// suggestions drawn from obj's own JSON field names. Gated on the strict
|
||||
// flag so an already-structured "unknown field" error bubbling up from a
|
||||
// nested UnmarshalJSON is passed through unchanged, not re-wrapped here with
|
||||
// the wrong (outer) field set.
|
||||
if bindBodyOptions.DisallowUnknownFields && strings.Contains(err.Error(), "unknown field") {
|
||||
if field := extractUnknownField(err.Error()); field != "" {
|
||||
message := "unknown field %q"
|
||||
if bindBodyOptions.UnknownFieldContext != "" {
|
||||
message = "unknown field %q in " + bindBodyOptions.UnknownFieldContext
|
||||
}
|
||||
|
||||
return errors.
|
||||
NewInvalidInputf(errors.CodeInvalidInput, message, field).
|
||||
WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field, JSONFieldNames(obj))...)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// JSONFieldNames returns the JSON field names of a struct (or pointer to one),
|
||||
// skipping fields tagged "-" or without a json tag.
|
||||
func JSONFieldNames(v any) []string {
|
||||
var fields []string
|
||||
|
||||
t := reflect.TypeOf(v)
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
if t.Kind() != reflect.Struct {
|
||||
return fields
|
||||
}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := strings.Split(jsonTag, ",")[0]
|
||||
if fieldName != "" {
|
||||
fields = append(fields, fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// extractUnknownField pulls fieldname out of a `json: unknown field "fieldname"`
|
||||
// decoder message, or returns "" when the message has no quoted field.
|
||||
func extractUnknownField(errMsg string) string {
|
||||
parts := strings.Split(errMsg, `"`)
|
||||
|
||||
if len(parts) >= 2 {
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -58,11 +58,89 @@ func TestJSONBinding_BindBodyErrors(t *testing.T) {
|
||||
err := JSON.BindBody(strings.NewReader(testCase.body), testCase.obj, testCase.opts...)
|
||||
assert.Error(t, err)
|
||||
|
||||
typ, c, m, _, _, a := errors.Unwrapb(err)
|
||||
typ, c, m, _, _, _ := errors.Unwrapb(err)
|
||||
assert.Equal(t, errors.TypeInvalidInput, typ)
|
||||
assert.Equal(t, testCase.code, c)
|
||||
assert.Equal(t, testCase.message, m)
|
||||
assert.ElementsMatch(t, testCase.a, a)
|
||||
|
||||
messages := []string{}
|
||||
for _, additional := range errors.AsJSON(err).Errors {
|
||||
messages = append(messages, additional.Message)
|
||||
}
|
||||
assert.ElementsMatch(t, testCase.a, messages)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type widget struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func TestJSONBinding_BindBody_UnknownFieldSuggestions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
opts []BindBodyOption
|
||||
message string
|
||||
suggestions []string
|
||||
}{
|
||||
{
|
||||
name: "NoNearMatch",
|
||||
body: `{"shape":"round"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true)},
|
||||
message: `unknown field "shape"`,
|
||||
suggestions: []string{"valid references: `name`, `color`"},
|
||||
},
|
||||
{
|
||||
name: "WithContext",
|
||||
body: `{"shape":"round"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true), WithUnknownFieldContext("widget spec")},
|
||||
message: `unknown field "shape" in widget spec`,
|
||||
suggestions: []string{"valid references: `name`, `color`"},
|
||||
},
|
||||
{
|
||||
name: "NearMatch",
|
||||
body: `{"nam":"x"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true)},
|
||||
message: `unknown field "nam"`,
|
||||
suggestions: []string{"did you mean: `name`", "valid references: `name`, `color`"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := JSON.BindBody(strings.NewReader(testCase.body), &widget{}, testCase.opts...)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, c, m, _, _, _ := errors.Unwrapb(err)
|
||||
|
||||
if testCase.message != "" {
|
||||
assert.Equal(t, errors.CodeInvalidInput, c)
|
||||
assert.Equal(t, testCase.message, m)
|
||||
}
|
||||
|
||||
assert.Equal(t, testCase.suggestions, errors.AsJSON(err).Suggestions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type structuredUnknownField struct{}
|
||||
|
||||
func (*structuredUnknownField) UnmarshalJSON([]byte) error {
|
||||
return errors.
|
||||
NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q in inner spec", "foo").
|
||||
WithSuggestions("did you mean: `bar`")
|
||||
}
|
||||
|
||||
// A non-strict BindBody must pass through an already-structured "unknown field"
|
||||
// error returned by a nested UnmarshalJSON, not re-wrap it with the outer field set.
|
||||
func TestJSONBinding_BindBody_PassesThroughStructuredUnknownField(t *testing.T) {
|
||||
err := JSON.BindBody(strings.NewReader(`{}`), &structuredUnknownField{})
|
||||
assert.Error(t, err)
|
||||
|
||||
_, c, m, _, _, _ := errors.Unwrapb(err)
|
||||
assert.Equal(t, errors.CodeInvalidInput, c)
|
||||
assert.Equal(t, `unknown field "foo" in inner spec`, m)
|
||||
assert.Equal(t, []string{"did you mean: `bar`"}, errors.AsJSON(err).Suggestions)
|
||||
}
|
||||
|
||||
@@ -113,9 +113,11 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
openapi.WithHTTPStatus(handler.openAPIDef.SuccessStatusCode),
|
||||
)
|
||||
} else {
|
||||
// No response body (e.g. 204 No Content): omit the content type so the
|
||||
// spec doesn't declare a body for a bodyless response, which would make
|
||||
// clients try to decode an empty payload.
|
||||
opCtx.AddRespStructure(
|
||||
nil,
|
||||
openapi.WithContentType(handler.openAPIDef.ResponseContentType),
|
||||
openapi.WithHTTPStatus(handler.openAPIDef.SuccessStatusCode),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"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/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
@@ -45,7 +46,7 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
var queryRangeRequest qbtypes.QueryRangeRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
|
||||
if err := binding.JSON.BindBody(req.Body, &queryRangeRequest); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
@@ -186,7 +187,7 @@ func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request
|
||||
func (handler *handler) ReplaceVariables(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
var queryRangeRequest qbtypes.QueryRangeRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
|
||||
if err := binding.JSON.BindBody(req.Body, &queryRangeRequest); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
65
pkg/querier/collect.go
Normal file
65
pkg/querier/collect.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (q *querier) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, error) {
|
||||
stats := make(map[string]any)
|
||||
|
||||
tracesTable := fmt.Sprintf("%s.%s", telemetrytraces.DBName, telemetrytraces.SpanIndexV3TableName)
|
||||
logsTable := fmt.Sprintf("%s.%s", telemetrylogs.DBName, telemetrylogs.LogsV2TableName)
|
||||
metricsTable := fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4TableName)
|
||||
|
||||
var (
|
||||
traces uint64
|
||||
tracesLastSeenAt time.Time
|
||||
)
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), max(timestamp) FROM %s", tracesTable)).Scan(&traces, &tracesLastSeenAt); err == nil {
|
||||
stats["telemetry.traces.count"] = traces
|
||||
if tracesLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.traces.last_observed.time"] = tracesLastSeenAt.UTC()
|
||||
stats["telemetry.traces.last_observed.time_unix"] = tracesLastSeenAt.Unix()
|
||||
}
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect traces stats", errors.Attr(err))
|
||||
}
|
||||
|
||||
var (
|
||||
logs uint64
|
||||
logsLastSeenAt time.Time
|
||||
)
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), fromUnixTimestamp64Nano(max(timestamp)) FROM %s", logsTable)).Scan(&logs, &logsLastSeenAt); err == nil {
|
||||
stats["telemetry.logs.count"] = logs
|
||||
if logsLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.logs.last_observed.time"] = logsLastSeenAt.UTC()
|
||||
stats["telemetry.logs.last_observed.time_unix"] = logsLastSeenAt.Unix()
|
||||
}
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect logs stats", errors.Attr(err))
|
||||
}
|
||||
|
||||
var (
|
||||
metrics uint64
|
||||
metricsLastSeenAt time.Time
|
||||
)
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), toDateTime(max(unix_milli) / 1000) FROM %s", metricsTable)).Scan(&metrics, &metricsLastSeenAt); err == nil {
|
||||
stats["telemetry.metrics.count"] = metrics
|
||||
if metricsLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.metrics.last_observed.time"] = metricsLastSeenAt.UTC()
|
||||
stats["telemetry.metrics.last_observed.time_unix"] = metricsLastSeenAt.Unix()
|
||||
}
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect metrics stats", errors.Attr(err))
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
type Querier interface {
|
||||
QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error)
|
||||
QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream)
|
||||
statsreporter.StatsCollector
|
||||
}
|
||||
|
||||
// BucketCache is the interface for bucket-based caching.
|
||||
|
||||
@@ -32,7 +32,7 @@ func CollisionHandledFinalExpr(
|
||||
|
||||
if requiredDataType != telemetrytypes.FieldDataTypeString &&
|
||||
requiredDataType != telemetrytypes.FieldDataTypeFloat64 {
|
||||
return "", nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "unsupported data type %s", requiredDataType)
|
||||
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported data type %s", requiredDataType)
|
||||
}
|
||||
|
||||
var dummyValue any
|
||||
@@ -81,14 +81,8 @@ func CollisionHandledFinalExpr(
|
||||
// - it is not a static field
|
||||
// - the next best thing to do is see if there is a typo
|
||||
// and suggest a correction
|
||||
correction, found := telemetrytypes.SuggestCorrection(field.Name, maps.Keys(keys))
|
||||
if found {
|
||||
// we found a close match, in the error message send the suggestion
|
||||
return "", nil, errors.WithAdditionalf(fieldForErr, "%s", correction)
|
||||
} else {
|
||||
// not even a close match, return an error
|
||||
return "", nil, errors.WithAdditionalf(fieldForErr, "field `%s` not found", field.Name)
|
||||
}
|
||||
wrappedErr := errors.WithSuggestiveAdditionalf(fieldForErr, errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys)), "field `%s` not found", field.Name)
|
||||
return "", nil, wrappedErr
|
||||
} else {
|
||||
for _, key := range keysForField {
|
||||
err := addCondition(key)
|
||||
|
||||
@@ -294,24 +294,30 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
validKeys = append(validKeys, k)
|
||||
}
|
||||
sort.Strings(validKeys)
|
||||
additional := []string{"Valid references are: [" + strings.Join(validKeys, ", ") + "]"}
|
||||
// Each suggestion is a self-describing string prefixed with either
|
||||
// "did you mean: " (the full corrected expression) or "valid references: "
|
||||
// (the set of valid references).
|
||||
var suggestions []string
|
||||
if len(v.invalid) == 1 {
|
||||
inv := v.invalid[0]
|
||||
// Only suggest for plain identifier typos, not for unresolved function
|
||||
// calls: a function call will appear as "name(" in the expression, and
|
||||
// the closest valid key may itself contain "(" (e.g. "sum(a)"), making
|
||||
// a simple string substitution produce a corrupt expression.
|
||||
isFuncCall := strings.Contains(original, inv+"(")
|
||||
if match, dist := closestMatch(inv, validKeys); !isFuncCall && !strings.Contains(match, "(") && dist <= 3 {
|
||||
corrected := strings.ReplaceAll(original, inv, match)
|
||||
additional = append(additional, "Suggestion: `"+corrected+"`")
|
||||
}
|
||||
suggestions = errors.SuggestionsFromFunc(func() string {
|
||||
match, ok := errors.ClosestLevenshteinMatch(inv, validKeys)
|
||||
if !ok || strings.Contains(original, inv+"(") || strings.Contains(match, "(") {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(original, inv, match)
|
||||
})
|
||||
}
|
||||
return "", errors.NewInvalidInputf(
|
||||
|
||||
suggestions = append(suggestions, errors.ValidReferences(validKeys...))
|
||||
havingErr := errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"Invalid references in `Having` expression: [%s]",
|
||||
strings.Join(v.invalid, ", "),
|
||||
).WithAdditional(additional...)
|
||||
).WithAdditional(
|
||||
"Valid references are: [" + strings.Join(validKeys, ", ") + "]",
|
||||
).WithSuggestions(suggestions...)
|
||||
return "", havingErr
|
||||
}
|
||||
|
||||
// Layer 3 – ANTLR syntax errors. We parse the original expression, so error messages
|
||||
@@ -324,21 +330,22 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
}
|
||||
detail := strings.Join(msgs, "; ")
|
||||
if detail == "" {
|
||||
detail = "check the expression syntax"
|
||||
}
|
||||
additional := []string{detail}
|
||||
// For single-error expressions, try to produce an actionable suggestion.
|
||||
if len(allSyntaxErrors) == 1 {
|
||||
if s := havingSuggestion(allSyntaxErrors[0], original); s != "" {
|
||||
additional = append(additional, "Suggestion: `"+s+"`")
|
||||
}
|
||||
}
|
||||
return "", errors.NewInvalidInputf(
|
||||
havingErr := errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"Syntax error in `Having` expression",
|
||||
).WithAdditional(additional...)
|
||||
)
|
||||
|
||||
// A single syntax error can carry an actionable suggestion on the same detail;
|
||||
// multiple errors are surfaced as one additional detail each. If the parser
|
||||
// produced no message (rare), the top-level message stands on its own.
|
||||
if len(allSyntaxErrors) == 1 && len(msgs) == 1 {
|
||||
suggestions := errors.SuggestionsFromFunc(func() string {
|
||||
return havingSuggestion(allSyntaxErrors[0], original)
|
||||
})
|
||||
|
||||
return "", havingErr.WithSuggestiveAdditional(msgs[0], suggestions...)
|
||||
}
|
||||
return "", havingErr.WithAdditional(msgs...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -448,42 +455,6 @@ func hasUnclosedBracket(s string) bool {
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// closestMatch returns the element of candidates with the smallest Levenshtein
|
||||
// distance to query, along with that distance.
|
||||
func closestMatch(query string, candidates []string) (string, int) {
|
||||
best, bestDist := "", -1
|
||||
for _, c := range candidates {
|
||||
if d := levenshtein(query, c); bestDist < 0 || d < bestDist {
|
||||
best, bestDist = c, d
|
||||
}
|
||||
}
|
||||
return best, bestDist
|
||||
}
|
||||
|
||||
// levenshtein computes the edit distance between a and b.
|
||||
func levenshtein(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
la, lb := len(ra), len(rb)
|
||||
row := make([]int, lb+1)
|
||||
for j := range row {
|
||||
row[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
prev := row[0]
|
||||
row[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
tmp := row[j]
|
||||
if ra[i-1] == rb[j-1] {
|
||||
row[j] = prev
|
||||
} else {
|
||||
row[j] = 1 + min(prev, min(row[j], row[j-1]))
|
||||
}
|
||||
prev = tmp
|
||||
}
|
||||
}
|
||||
return row[lb]
|
||||
}
|
||||
|
||||
// endsWithComparisonOp reports whether s ends with a comparison operator token
|
||||
// (longer operators are checked first to avoid ">=" being matched by ">").
|
||||
func endsWithComparisonOp(s string) bool {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user