mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-16 21:40:34 +01:00
Compare commits
13 Commits
feat/compo
...
issue_5365
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb9d1d25be | ||
|
|
e1e9d516ac | ||
|
|
58b55c922d | ||
|
|
629ea3b8be | ||
|
|
287b60cbe6 | ||
|
|
e206625e5f | ||
|
|
4f3b7647d3 | ||
|
|
76ab0f25c0 | ||
|
|
59501ce4a7 | ||
|
|
3e51b9556e | ||
|
|
abd4436388 | ||
|
|
30f52ecb6d | ||
|
|
629d24547c |
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,10 +409,6 @@ components:
|
||||
properties:
|
||||
duration:
|
||||
type: string
|
||||
endTime:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
repeatOn:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesRepeatOn'
|
||||
@@ -420,11 +416,7 @@ components:
|
||||
type: array
|
||||
repeatType:
|
||||
$ref: '#/components/schemas/AlertmanagertypesRepeatType'
|
||||
startTime:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- startTime
|
||||
- duration
|
||||
- repeatType
|
||||
type: object
|
||||
@@ -458,6 +450,7 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- timezone
|
||||
- startTime
|
||||
type: object
|
||||
AuthtypesAttributeMapping:
|
||||
properties:
|
||||
@@ -3573,10 +3566,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/ErrorsResponseerroradditional'
|
||||
type: array
|
||||
invalidReferences:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
message:
|
||||
type: string
|
||||
retry:
|
||||
@@ -3597,6 +3586,10 @@ components:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
suggestions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
ErrorsResponseretryjson:
|
||||
properties:
|
||||
@@ -9011,10 +9004,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -9167,10 +9156,6 @@ paths:
|
||||
$ref: '#/components/schemas/DashboardtypesUpdatablePublicDashboard'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -9765,10 +9750,6 @@ paths:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
@@ -10210,6 +10191,15 @@ paths:
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: q
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: isOverride
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -10953,10 +10943,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11070,10 +11056,6 @@ paths:
|
||||
$ref: '#/components/schemas/AuthtypesPatchableRole'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11220,10 +11202,6 @@ paths:
|
||||
$ref: '#/components/schemas/CoretypesPatchableObjects'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -11673,10 +11651,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11784,10 +11758,6 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -11969,10 +11939,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -12030,10 +11996,6 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableFactorAPIKey'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -12216,10 +12178,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -12295,10 +12253,6 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"404":
|
||||
content:
|
||||
@@ -12790,6 +12744,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
|
||||
@@ -13476,10 +13477,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -13739,10 +13736,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -13795,10 +13788,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -15529,10 +15518,6 @@ paths:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesUpdateMetricMetadataRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
@@ -20831,10 +20816,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -20882,10 +20863,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -413,21 +413,11 @@ export interface AlertmanagertypesRecurrenceDTO {
|
||||
* @type string
|
||||
*/
|
||||
duration: string;
|
||||
/**
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
endTime?: string | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
|
||||
repeatType: AlertmanagertypesRepeatTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesScheduleDTO {
|
||||
@@ -441,7 +431,7 @@ export interface AlertmanagertypesScheduleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime?: string;
|
||||
startTime: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2153,6 +2143,10 @@ export interface ErrorsResponseerroradditionalDTO {
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
export interface ErrorsResponseretryjsonDTO {
|
||||
@@ -2168,10 +2162,6 @@ export interface ErrorsJSONDTO {
|
||||
* @type array
|
||||
*/
|
||||
errors?: ErrorsResponseerroradditionalDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
invalidReferences?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9398,6 +9388,16 @@ export type ListLLMPricingRulesParams = {
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
q?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
* @description undefined
|
||||
*/
|
||||
isOverride?: boolean | null;
|
||||
};
|
||||
|
||||
export type ListLLMPricingRules200 = {
|
||||
@@ -9746,6 +9746,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;
|
||||
};
|
||||
@@ -6,10 +6,6 @@ import {
|
||||
import { SelectOption } from 'types/common/select';
|
||||
|
||||
export const metricAggregateOperatorOptions: SelectOption<string, string>[] = [
|
||||
{
|
||||
value: MetricAggregateOperator.NOOP,
|
||||
label: 'No aggregation',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.COUNT,
|
||||
label: 'Count',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
@@ -295,11 +294,11 @@ function collectSharedMetadata(
|
||||
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
|
||||
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
|
||||
if (compositeQueryRaw) {
|
||||
// Decode through the serializer seam (handles every tier + malformed
|
||||
// input → null); never JSON.parse the raw URL value.
|
||||
const decodedQuery = deserialize(compositeQueryRaw);
|
||||
if (decodedQuery) {
|
||||
out.query = decodedQuery;
|
||||
try {
|
||||
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
|
||||
} catch {
|
||||
// Malformed JSON in the URL — drop silently rather than throw
|
||||
// inside a context-collection helper.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
.billingContainer {
|
||||
margin-bottom: var(--spacing-20);
|
||||
padding-top: 36px;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
margin: 0 auto var(--spacing-20);
|
||||
|
||||
.pageHeader {
|
||||
margin-bottom: var(--spacing-8);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.license-key-callout {
|
||||
margin: var(--spacing-4) var(--spacing-6);
|
||||
width: auto;
|
||||
width: auto !important;
|
||||
|
||||
.license-key-callout__description {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useQueries } from 'react-query';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import GeneralSettings from '../index';
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
const baseQueryResult = {
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isSuccess: true,
|
||||
data: undefined,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
};
|
||||
|
||||
describe('GeneralSettings index', () => {
|
||||
it('renders fallback message when logs query fails with a non-APIError', () => {
|
||||
(useQueries as jest.Mock).mockReturnValue([
|
||||
{ ...baseQueryResult },
|
||||
{ ...baseQueryResult },
|
||||
{
|
||||
...baseQueryResult,
|
||||
isError: true,
|
||||
isSuccess: false,
|
||||
error: new TypeError(
|
||||
"Cannot read properties of undefined (reading 'code')",
|
||||
),
|
||||
},
|
||||
{ ...baseQueryResult },
|
||||
]);
|
||||
|
||||
render(<GeneralSettings />);
|
||||
|
||||
expect(screen.getByText('something_went_wrong')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,9 @@ function GeneralSettings(): JSX.Element {
|
||||
if (getRetentionPeriodLogsApiResponse.isError || getDisksResponse.isError) {
|
||||
return (
|
||||
<Typography>
|
||||
{(getRetentionPeriodLogsApiResponse.error as APIError).getErrorMessage() ||
|
||||
{(getRetentionPeriodLogsApiResponse.error instanceof APIError
|
||||
? getRetentionPeriodLogsApiResponse.error.getErrorMessage()
|
||||
: undefined) ||
|
||||
getDisksResponse.data?.error ||
|
||||
t('something_went_wrong')}
|
||||
</Typography>
|
||||
|
||||
@@ -86,9 +86,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
|
||||
},
|
||||
{
|
||||
id: 'capacity',
|
||||
header: 'Volume Capacity',
|
||||
header: 'Capacity',
|
||||
accessorFn: (row): number => row.volumeCapacity,
|
||||
width: { min: 220 },
|
||||
width: { min: 140 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const capacity = value as number;
|
||||
@@ -105,9 +105,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
|
||||
},
|
||||
{
|
||||
id: 'usage',
|
||||
header: 'Volume Utilization',
|
||||
header: 'Used',
|
||||
accessorFn: (row): number => row.volumeUsage,
|
||||
width: { min: 220 },
|
||||
width: { min: 140 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const usage = value as number;
|
||||
@@ -124,9 +124,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
|
||||
},
|
||||
{
|
||||
id: 'available',
|
||||
header: 'Volume Available',
|
||||
header: 'Available',
|
||||
accessorFn: (row): number => row.volumeAvailable,
|
||||
width: { min: 220 },
|
||||
width: { min: 140 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const available = value as number;
|
||||
@@ -141,4 +141,61 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inodes',
|
||||
header: 'Inodes',
|
||||
accessorFn: (row): number => row.volumeInodes,
|
||||
width: { min: 140 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const inodes = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={inodes}
|
||||
entity={InfraMonitoringEntity.VOLUMES}
|
||||
attribute="inodes metric"
|
||||
>
|
||||
<TanStackTable.Text>{inodes}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inodesUsed',
|
||||
header: 'Inodes Used',
|
||||
accessorFn: (row): number => row.volumeInodesUsed,
|
||||
width: { min: 160 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const inodesUsed = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={inodesUsed}
|
||||
entity={InfraMonitoringEntity.VOLUMES}
|
||||
attribute="inodes used metric"
|
||||
>
|
||||
<TanStackTable.Text>{inodesUsed}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inodesFree',
|
||||
header: 'Inodes Free',
|
||||
accessorFn: (row): number => row.volumeInodesFree,
|
||||
width: { min: 160 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const inodesFree = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={inodesFree}
|
||||
entity={InfraMonitoringEntity.VOLUMES}
|
||||
attribute="inodes free metric"
|
||||
>
|
||||
<TanStackTable.Text>{inodesFree}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -151,6 +151,11 @@ export function PlannedDowntimeForm(
|
||||
|
||||
const saveHandler = useCallback(
|
||||
async (values: PlannedDowntimeFormData) => {
|
||||
const { startTime, timezone } = values;
|
||||
if (!startTime || !timezone) {
|
||||
// unreachable: required fields should always be present on submitting.
|
||||
return;
|
||||
}
|
||||
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
|
||||
alertIds:
|
||||
values.alertRuleScope === 'all'
|
||||
@@ -161,9 +166,9 @@ export function PlannedDowntimeForm(
|
||||
name: values.name,
|
||||
scope: values.scope,
|
||||
schedule: {
|
||||
startTime: values.startTime?.format(),
|
||||
startTime: startTime.format(),
|
||||
endTime: values.endTime?.format(),
|
||||
timezone: values.timezone!,
|
||||
timezone,
|
||||
recurrence: values.recurrence,
|
||||
},
|
||||
};
|
||||
@@ -200,25 +205,17 @@ export function PlannedDowntimeForm(
|
||||
],
|
||||
);
|
||||
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
|
||||
const { recurrence } = values;
|
||||
const recurrenceData =
|
||||
!recurrence ||
|
||||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
|
||||
? undefined
|
||||
: {
|
||||
duration: recurrence.duration
|
||||
? `${recurrence.duration}${durationUnit}`
|
||||
: '',
|
||||
startTime: values.startTime!.format(),
|
||||
endTime: values.endTime?.format(),
|
||||
repeatOn: recurrence.repeatOn,
|
||||
repeatType: recurrence.repeatType,
|
||||
};
|
||||
const rec = values.recurrence;
|
||||
const recurrence =
|
||||
rec && rec.repeatType !== recurrenceOptions.doesNotRepeat.value
|
||||
? {
|
||||
duration: `${rec.duration}${durationUnit}`,
|
||||
repeatOn: rec.repeatOn,
|
||||
repeatType: rec.repeatType,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await saveHandler({
|
||||
...values,
|
||||
recurrence: recurrenceData,
|
||||
});
|
||||
await saveHandler({ ...values, recurrence });
|
||||
};
|
||||
|
||||
const handleFormData = (data: Partial<PlannedDowntimeFormData>): void => {
|
||||
@@ -275,9 +272,6 @@ export function PlannedDowntimeForm(
|
||||
|
||||
const formattedInitialValues = useMemo((): PlannedDowntimeFormData => {
|
||||
const { schedule } = initialValues;
|
||||
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
|
||||
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
|
||||
|
||||
const initialAlertIds = initialValues.alertIds || [];
|
||||
|
||||
return {
|
||||
@@ -285,8 +279,12 @@ export function PlannedDowntimeForm(
|
||||
alertRuleScope:
|
||||
isEditMode && initialAlertIds.length === 0 ? 'all' : 'specific',
|
||||
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
|
||||
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
|
||||
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
|
||||
startTime: schedule?.startTime
|
||||
? dayjs(schedule.startTime).tz(schedule.timezone)
|
||||
: null,
|
||||
endTime: schedule?.endTime
|
||||
? dayjs(schedule.endTime).tz(schedule.timezone)
|
||||
: null,
|
||||
recurrence: {
|
||||
...schedule?.recurrence,
|
||||
repeatType: !isScheduleRecurring(schedule)
|
||||
@@ -297,7 +295,7 @@ export function PlannedDowntimeForm(
|
||||
timezone: schedule?.timezone as string,
|
||||
scope: initialValues.scope || '',
|
||||
};
|
||||
}, [initialValues, alertOptions]);
|
||||
}, [initialValues, isEditMode, alertOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTags(formattedInitialValues.alertRules);
|
||||
@@ -341,7 +339,7 @@ export function PlannedDowntimeForm(
|
||||
const formattedEndTime = endTime.format(TIME_FORMAT);
|
||||
const formattedEndDate = endTime.format(DATE_FORMAT);
|
||||
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
|
||||
}, [formData, recurrenceType]);
|
||||
}, [formData]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -142,7 +142,6 @@ export function CollapseListContent({
|
||||
updated_by_name?: string;
|
||||
alertOptions?: DefaultOptionType[];
|
||||
}): JSX.Element {
|
||||
const repeats = schedule?.recurrence;
|
||||
const renderItems = (title: string, value: ReactNode): JSX.Element => (
|
||||
<div className="render-item-collapse-list">
|
||||
<Typography>{title}</Typography>
|
||||
@@ -193,10 +192,7 @@ export function CollapseListContent({
|
||||
'Timezone',
|
||||
<Typography>{schedule?.timezone || '-'}</Typography>,
|
||||
)}
|
||||
{renderItems(
|
||||
'Repeats',
|
||||
<Typography>{recurrenceInfo(repeats, schedule?.timezone)}</Typography>,
|
||||
)}
|
||||
{renderItems('Repeats', <Typography>{recurrenceInfo(schedule)}</Typography>)}
|
||||
{renderItems(
|
||||
'Alerts silenced',
|
||||
alertOptions?.length ? (
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
DeleteDowntimeScheduleByIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
AlertmanagertypesPlannedMaintenanceDTO,
|
||||
AlertmanagertypesRecurrenceDTO,
|
||||
AlertmanagertypesScheduleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { AxiosError } from 'axios';
|
||||
@@ -66,14 +66,17 @@ export const getAlertOptionsFromIds = (
|
||||
);
|
||||
|
||||
export const recurrenceInfo = (
|
||||
recurrence?: AlertmanagertypesRecurrenceDTO | null,
|
||||
timezone?: string,
|
||||
schedule?: AlertmanagertypesScheduleDTO | null,
|
||||
): string => {
|
||||
if (!schedule) {
|
||||
return 'No';
|
||||
}
|
||||
const { startTime, endTime, timezone, recurrence } = schedule;
|
||||
if (!recurrence) {
|
||||
return 'No';
|
||||
}
|
||||
|
||||
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
|
||||
const { duration, repeatOn, repeatType } = recurrence;
|
||||
|
||||
const formattedStartTime = startTime
|
||||
? formatDateTime(startTime, timezone)
|
||||
@@ -95,7 +98,7 @@ export const defaultInitialValues: Partial<AlertmanagertypesPlannedMaintenanceDT
|
||||
timezone: '',
|
||||
endTime: undefined,
|
||||
recurrence: undefined,
|
||||
startTime: undefined,
|
||||
startTime: '',
|
||||
},
|
||||
alertIds: [],
|
||||
createdAt: undefined,
|
||||
|
||||
@@ -11,7 +11,7 @@ export const buildSchedule = (
|
||||
schedule: Partial<AlertmanagertypesScheduleDTO>,
|
||||
): AlertmanagertypesScheduleDTO => ({
|
||||
timezone: schedule?.timezone ?? '',
|
||||
startTime: schedule?.startTime,
|
||||
startTime: schedule?.startTime ?? '',
|
||||
endTime: schedule?.endTime,
|
||||
recurrence: schedule?.recurrence,
|
||||
});
|
||||
|
||||
@@ -1135,17 +1135,9 @@
|
||||
|
||||
.settings-dropdown,
|
||||
.help-support-dropdown {
|
||||
.ant-dropdown-menu-item {
|
||||
min-height: 32px;
|
||||
|
||||
.ant-dropdown-menu-title-content {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
.user-settings-dropdown-logout-section {
|
||||
color: var(--danger-background);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.user-settings-dropdown-logout-section {
|
||||
color: var(--danger-background);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import AutoRefresh from '../AutoRefreshV2';
|
||||
@@ -300,7 +299,7 @@ function DateTimeSelection({
|
||||
})),
|
||||
},
|
||||
};
|
||||
return serialize(updatedCompositeQuery);
|
||||
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
|
||||
}, [currentQuery]);
|
||||
|
||||
const onSelectHandler = useCallback(
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
|
||||
let mockUrlQuery = new URLSearchParams();
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => mockUrlQuery,
|
||||
}));
|
||||
|
||||
describe('useGetCompositeQueryParam', () => {
|
||||
it('decodes a legacy compositeQuery param', () => {
|
||||
mockUrlQuery = new URLSearchParams({
|
||||
compositeQuery: encodeURIComponent(JSON.stringify(initialQueriesMap.logs)),
|
||||
});
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam());
|
||||
expect(result.current?.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
|
||||
it('returns null when the param is absent', () => {
|
||||
mockUrlQuery = new URLSearchParams();
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
convertHavingToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { useMemo } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null => {
|
||||
@@ -9,9 +14,59 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
|
||||
return useMemo(() => {
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
|
||||
if (!compositeQuery) {
|
||||
return null;
|
||||
let parsedCompositeQuery: Query | null = null;
|
||||
|
||||
try {
|
||||
if (!compositeQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
|
||||
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
|
||||
parsedCompositeQuery = JSON.parse(
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
parsedCompositeQuery.builder.queryData =
|
||||
parsedCompositeQuery.builder.queryData.map((query) => {
|
||||
const existingExpression = query.filter?.expression || '';
|
||||
const convertedQuery = { ...query };
|
||||
|
||||
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
|
||||
query.filters || { items: [], op: 'AND' },
|
||||
existingExpression,
|
||||
);
|
||||
convertedQuery.filter = convertedFilter.filter;
|
||||
convertedQuery.filters = convertedFilter.filters;
|
||||
|
||||
// Convert having if needed
|
||||
if (Array.isArray(query.having)) {
|
||||
const convertedHaving = convertHavingToExpression(query.having);
|
||||
convertedQuery.having = convertedHaving;
|
||||
}
|
||||
|
||||
// Convert aggregation if needed
|
||||
if (!query.aggregations && query.aggregateOperator) {
|
||||
const convertedAggregation = convertAggregationToExpression({
|
||||
aggregateOperator: query.aggregateOperator,
|
||||
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
|
||||
dataSource: query.dataSource,
|
||||
timeAggregation: query.timeAggregation,
|
||||
spaceAggregation: query.spaceAggregation,
|
||||
reduceTo: query.reduceTo,
|
||||
temporality: query.temporality,
|
||||
}) as any; // Type assertion to handle union type
|
||||
convertedQuery.aggregations = convertedAggregation;
|
||||
}
|
||||
return convertedQuery;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
parsedCompositeQuery = null;
|
||||
}
|
||||
return deserialize(compositeQuery);
|
||||
|
||||
return parsedCompositeQuery;
|
||||
}, [urlQuery]);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
interface NavigateOptions {
|
||||
@@ -39,20 +38,16 @@ const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode through the serializer seam: the URL value may be in any
|
||||
// tier (legacy JSON or a tagged format), so never JSON.parse it raw.
|
||||
const decoded1 = deserialize(query1);
|
||||
const decoded2 = deserialize(query2);
|
||||
const decoded1 = JSON.parse(decodeURIComponent(query1));
|
||||
const decoded2 = JSON.parse(decodeURIComponent(query2));
|
||||
|
||||
if (!decoded1 || !decoded2) {
|
||||
return false;
|
||||
}
|
||||
const filtered1 = cloneDeep(decoded1);
|
||||
const filtered2 = cloneDeep(decoded2);
|
||||
|
||||
// Ignore the volatile `id` when comparing queries.
|
||||
const { id: _id1, ...rest1 } = decoded1;
|
||||
const { id: _id2, ...rest2 } = decoded2;
|
||||
delete filtered1.id;
|
||||
delete filtered2.id;
|
||||
|
||||
return isEqual(rest1, rest2);
|
||||
return isEqual(filtered1, filtered2);
|
||||
} catch (error) {
|
||||
console.warn('Error comparing compositeQuery:', error);
|
||||
return false;
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
//
|
||||
// ╔════════════════════════════════════════════════════════════════════════════╗
|
||||
// ║ ⚠️ NEVER UPDATE THESE ⚠️ ║
|
||||
// ╠════════════════════════════════════════════════════════════════════════════╣
|
||||
// ║ These snapshots guard URL backward compatibility. Every emitted URL ║
|
||||
// ║ encodes a diff against these exact baselines. ║
|
||||
// ║ ║
|
||||
// ║ If a test fails: REVERT baseline.ts, do NOT update snapshots. ║
|
||||
// ║ If you need a new schema: create BASELINE_V2 + new adapter prefix. ║
|
||||
// ╚════════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
exports[`baseline immutability snapshots LOGS_BASELINE must never change 1`] = `
|
||||
{
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "",
|
||||
"id": "----",
|
||||
"key": "",
|
||||
"type": "",
|
||||
},
|
||||
"aggregateOperator": null,
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() ",
|
||||
},
|
||||
],
|
||||
"dataSource": "logs",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filter": {
|
||||
"expression": "",
|
||||
},
|
||||
"filters": {
|
||||
"items": [],
|
||||
"op": "AND",
|
||||
},
|
||||
"functions": [],
|
||||
"groupBy": [],
|
||||
"having": [],
|
||||
"legend": "",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"source": null,
|
||||
"spaceAggregation": "sum",
|
||||
"stepInterval": null,
|
||||
"timeAggregation": "rate",
|
||||
},
|
||||
],
|
||||
"queryFormulas": [],
|
||||
"queryTraceOperator": [],
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"id": "",
|
||||
"promql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"queryType": "builder",
|
||||
"unit": "",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`baseline immutability snapshots METRICS_BASELINE must never change 1`] = `
|
||||
{
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "",
|
||||
"id": "----",
|
||||
"key": "",
|
||||
"type": "",
|
||||
},
|
||||
"aggregateOperator": "noop",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "",
|
||||
"reduceTo": "avg",
|
||||
"spaceAggregation": "sum",
|
||||
"temporality": "",
|
||||
"timeAggregation": "avg",
|
||||
},
|
||||
],
|
||||
"dataSource": "metrics",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filter": {
|
||||
"expression": "",
|
||||
},
|
||||
"filters": {
|
||||
"items": [],
|
||||
"op": "AND",
|
||||
},
|
||||
"functions": [],
|
||||
"groupBy": [],
|
||||
"having": [],
|
||||
"legend": "",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"source": null,
|
||||
"spaceAggregation": "sum",
|
||||
"stepInterval": null,
|
||||
"timeAggregation": "rate",
|
||||
},
|
||||
],
|
||||
"queryFormulas": [],
|
||||
"queryTraceOperator": [],
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"id": "",
|
||||
"promql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"queryType": "builder",
|
||||
"unit": "",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`baseline immutability snapshots TRACES_BASELINE must never change 1`] = `
|
||||
{
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "",
|
||||
"id": "----",
|
||||
"key": "",
|
||||
"type": "",
|
||||
},
|
||||
"aggregateOperator": null,
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() ",
|
||||
},
|
||||
],
|
||||
"dataSource": "traces",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filter": {
|
||||
"expression": "",
|
||||
},
|
||||
"filters": {
|
||||
"items": [],
|
||||
"op": "AND",
|
||||
},
|
||||
"functions": [],
|
||||
"groupBy": [],
|
||||
"having": [],
|
||||
"legend": "",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"source": null,
|
||||
"spaceAggregation": "sum",
|
||||
"stepInterval": null,
|
||||
"timeAggregation": "rate",
|
||||
},
|
||||
],
|
||||
"queryFormulas": [],
|
||||
"queryTraceOperator": [],
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"id": "",
|
||||
"promql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"queryType": "builder",
|
||||
"unit": "",
|
||||
}
|
||||
`;
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* ╔════════════════════════════════════════════════════════════════════════════╗
|
||||
* ║ ⚠️ CRITICAL WARNING ⚠️ ║
|
||||
* ╠════════════════════════════════════════════════════════════════════════════╣
|
||||
* ║ These baselines are FROZEN FOREVER. They must NEVER be modified. ║
|
||||
* ║ ║
|
||||
* ║ WHY: Every URL ever emitted by the compositeQuery serializer encodes a ║
|
||||
* ║ diff against these exact baselines. Changing a single byte here silently ║
|
||||
* ║ BREAKS ALL EXISTING URLs — dashboards, saved views, shared links, etc. ║
|
||||
* ║ ║
|
||||
* ║ If these snapshot tests fail: ║
|
||||
* ║ 1. DO NOT update the snapshots ║
|
||||
* ║ 2. REVERT your changes to baseline.ts immediately ║
|
||||
* ║ 3. If you need a new schema, create a NEW versioned baseline: ║
|
||||
* ║ - METRICS_BASELINE_V2, LOGS_BASELINE_V2, TRACES_BASELINE_V2 ║
|
||||
* ║ - Create a new adapter (e.g., V2~) that uses the new baselines ║
|
||||
* ║ - Keep the old baselines untouched for backwards compatibility ║
|
||||
* ╚════════════════════════════════════════════════════════════════════════════╝
|
||||
*/
|
||||
|
||||
import getBaselineByTag, {
|
||||
LOGS_BASELINE,
|
||||
METRICS_BASELINE,
|
||||
pickBaseline,
|
||||
TRACES_BASELINE,
|
||||
} from '../baseline';
|
||||
|
||||
describe('baseline immutability snapshots', () => {
|
||||
/**
|
||||
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
|
||||
* If this fails, you broke URL compatibility. Revert your changes.
|
||||
*/
|
||||
it('METRICS_BASELINE must never change', () => {
|
||||
expect(METRICS_BASELINE).toMatchSnapshot();
|
||||
});
|
||||
|
||||
/**
|
||||
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
|
||||
* If this fails, you broke URL compatibility. Revert your changes.
|
||||
*/
|
||||
it('LOGS_BASELINE must never change', () => {
|
||||
expect(LOGS_BASELINE).toMatchSnapshot();
|
||||
});
|
||||
|
||||
/**
|
||||
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
|
||||
* If this fails, you broke URL compatibility. Revert your changes.
|
||||
*/
|
||||
it('TRACES_BASELINE must never change', () => {
|
||||
expect(TRACES_BASELINE).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickBaseline', () => {
|
||||
it('returns metrics baseline for metrics dataSource', () => {
|
||||
const query = {
|
||||
builder: { queryData: [{ dataSource: 'metrics' }] },
|
||||
} as any;
|
||||
|
||||
const result = pickBaseline(query);
|
||||
|
||||
expect(result.baseline).toBe(METRICS_BASELINE);
|
||||
expect(result.tag).toBe('m');
|
||||
});
|
||||
|
||||
it('returns logs baseline for logs dataSource', () => {
|
||||
const query = {
|
||||
builder: { queryData: [{ dataSource: 'logs' }] },
|
||||
} as any;
|
||||
|
||||
const result = pickBaseline(query);
|
||||
|
||||
expect(result.baseline).toBe(LOGS_BASELINE);
|
||||
expect(result.tag).toBe('l');
|
||||
});
|
||||
|
||||
it('returns traces baseline for traces dataSource', () => {
|
||||
const query = {
|
||||
builder: { queryData: [{ dataSource: 'traces' }] },
|
||||
} as any;
|
||||
|
||||
const result = pickBaseline(query);
|
||||
|
||||
expect(result.baseline).toBe(TRACES_BASELINE);
|
||||
expect(result.tag).toBe('t');
|
||||
});
|
||||
|
||||
it('defaults to metrics baseline for unknown dataSource', () => {
|
||||
const query = {
|
||||
builder: { queryData: [{ dataSource: 'unknown' }] },
|
||||
} as any;
|
||||
|
||||
const result = pickBaseline(query);
|
||||
|
||||
expect(result.baseline).toBe(METRICS_BASELINE);
|
||||
expect(result.tag).toBe('m');
|
||||
});
|
||||
|
||||
it('defaults to metrics baseline when queryData is empty', () => {
|
||||
const query = {
|
||||
builder: { queryData: [] },
|
||||
} as any;
|
||||
|
||||
const result = pickBaseline(query);
|
||||
|
||||
expect(result.baseline).toBe(METRICS_BASELINE);
|
||||
expect(result.tag).toBe('m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBaselineByTag', () => {
|
||||
it('returns LOGS_BASELINE for tag "l"', () => {
|
||||
expect(getBaselineByTag('l')).toBe(LOGS_BASELINE);
|
||||
});
|
||||
|
||||
it('returns TRACES_BASELINE for tag "t"', () => {
|
||||
expect(getBaselineByTag('t')).toBe(TRACES_BASELINE);
|
||||
});
|
||||
|
||||
it('returns METRICS_BASELINE for tag "m"', () => {
|
||||
expect(getBaselineByTag('m')).toBe(METRICS_BASELINE);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { deserialize, serialize } from 'lib/compositeQuery/serializer';
|
||||
|
||||
describe('composite query serializer', () => {
|
||||
it('serialize picks shortest format', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const serialized = serialize(query);
|
||||
const jsonSerialized = encodeURIComponent(JSON.stringify(query));
|
||||
// Serializer should pick a format shorter than or equal to raw JSON
|
||||
expect(serialized.length).toBeLessThanOrEqual(jsonSerialized.length);
|
||||
// Should use a tagged format with baseline indicator (m/l/t)
|
||||
const usesTaggedFormat =
|
||||
/^V1[mlt]~/.test(serialized) ||
|
||||
/^TV[mlt]~/.test(serialized) ||
|
||||
/^FV[mlt]~/.test(serialized) ||
|
||||
/^FK[mlt]~/.test(serialized);
|
||||
expect(usesTaggedFormat).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips through serialize/deserialize', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
const decoded = deserialize(serialize(query));
|
||||
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
|
||||
it('returns null on corrupt input instead of throwing', () => {
|
||||
expect(deserialize('%7Bnot-json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty/missing value', () => {
|
||||
expect(deserialize('')).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves id field through roundtrip', () => {
|
||||
const query = { ...initialQueriesMap.metrics, id: 'test-query-uuid-123' };
|
||||
const serialized = serialize(query);
|
||||
const decoded = deserialize(serialized);
|
||||
expect(decoded?.id).toBe('test-query-uuid-123');
|
||||
});
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Flat-keys codec: a baseline-diff serializer that shortens keys but keeps
|
||||
* values as raw JSON. Simpler than flat-leaf — no field-aware enum compression.
|
||||
*
|
||||
* Wire grammar (tokens joined by `*`):
|
||||
* set: <shortPath>_<escapedJSON> e.g. b.qd.0.ds_"logs"
|
||||
* delete: -<shortPath> e.g. -b.qd.0.ag.0.mn
|
||||
*/
|
||||
import getBaselineByTag, {
|
||||
BaselineTag,
|
||||
pickBaseline,
|
||||
} from 'lib/compositeQuery/baseline';
|
||||
import { INVERSE_KEY_MAP, KEY_MAP } from './maps';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
type Json = unknown;
|
||||
type PathSeg = string | number;
|
||||
|
||||
const PAIR = '*';
|
||||
const KV = '_';
|
||||
const DEL = '-';
|
||||
|
||||
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
|
||||
|
||||
const isContainer = (value: Json): value is Record<string, Json> | Json[] =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const isEmptyContainer = (value: Json): boolean =>
|
||||
isContainer(value) &&
|
||||
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
|
||||
|
||||
const isLeaf = (value: Json): boolean =>
|
||||
!isContainer(value) || isEmptyContainer(value);
|
||||
|
||||
const shortenSeg = (seg: PathSeg): PathSeg =>
|
||||
typeof seg === 'number' ? seg : (KEY_MAP[seg] ?? seg);
|
||||
|
||||
function leafMap(obj: Json): Record<string, Json> {
|
||||
const out: Record<string, Json> = {};
|
||||
const walk = (node: Json, segs: PathSeg[]): void => {
|
||||
if (isLeaf(node)) {
|
||||
out[segs.map(shortenSeg).join('.')] = node;
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((value, index) => walk(value, [...segs, index]));
|
||||
return;
|
||||
}
|
||||
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
|
||||
walk(value, [...segs, key]),
|
||||
);
|
||||
};
|
||||
walk(obj, []);
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeValue(str: string): string {
|
||||
let escaped = str.replace(/_/g, '__').replace(/\*/g, '_s');
|
||||
if (escaped[0] === '!') {
|
||||
escaped = `_i${escaped.slice(1)}`;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
const UNESCAPE: Record<string, string> = { _: '_', s: '*', i: '!' };
|
||||
|
||||
function unescapeValue(str: string): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i += 1) {
|
||||
if (str[i] === '_') {
|
||||
const next = str[i + 1];
|
||||
out += UNESCAPE[next] ?? next;
|
||||
i += 1;
|
||||
} else {
|
||||
out += str[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeLeaf(value: Json): string {
|
||||
if (value === undefined) {
|
||||
return escapeValue('null');
|
||||
}
|
||||
return escapeValue(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function decodeLeaf(token: string): Json {
|
||||
return JSON.parse(unescapeValue(token));
|
||||
}
|
||||
|
||||
function parsePath(pathStr: string): PathSeg[] {
|
||||
return pathStr
|
||||
.split('.')
|
||||
.map((seg) => (isIndex(seg) ? Number(seg) : (INVERSE_KEY_MAP[seg] ?? seg)));
|
||||
}
|
||||
|
||||
function setPath(
|
||||
root: Record<string, Json>,
|
||||
segs: PathSeg[],
|
||||
value: Json,
|
||||
): void {
|
||||
let node: Json = root;
|
||||
for (let i = 0; i < segs.length - 1; i += 1) {
|
||||
const seg = segs[i];
|
||||
const container = node as Record<string | number, Json>;
|
||||
if (!isContainer(container[seg])) {
|
||||
container[seg] = typeof segs[i + 1] === 'number' ? [] : {};
|
||||
}
|
||||
node = container[seg];
|
||||
}
|
||||
(node as Record<string | number, Json>)[segs[segs.length - 1]] = value;
|
||||
}
|
||||
|
||||
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
|
||||
const root: Record<string, Json> = {};
|
||||
Object.entries(map).forEach(([path, value]) => {
|
||||
setPath(root, parsePath(path), value);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
export function encode(query: Query): { payload: string; tag: BaselineTag } {
|
||||
const { baseline, tag } = pickBaseline(query);
|
||||
const base = leafMap(baseline);
|
||||
const next = leafMap(query);
|
||||
const tokens: string[] = [];
|
||||
Object.entries(next).forEach(([path, value]) => {
|
||||
const baseVal = base[path];
|
||||
const normalizedBase = baseVal === undefined ? null : baseVal;
|
||||
const normalizedNext = value === undefined ? null : value;
|
||||
if (
|
||||
path in base &&
|
||||
JSON.stringify(normalizedBase) === JSON.stringify(normalizedNext)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
tokens.push(`${path}${KV}${encodeLeaf(value)}`);
|
||||
});
|
||||
Object.keys(base).forEach((path) => {
|
||||
if (!(path in next)) {
|
||||
tokens.push(`${DEL}${path}`);
|
||||
}
|
||||
});
|
||||
tokens.sort();
|
||||
return { payload: tokens.join(PAIR), tag };
|
||||
}
|
||||
|
||||
export function decode(payload: string, tag: BaselineTag): Query {
|
||||
const map = leafMap(getBaselineByTag(tag));
|
||||
if (payload) {
|
||||
payload.split(PAIR).forEach((token) => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (token[0] === DEL) {
|
||||
delete map[token.slice(1)];
|
||||
return;
|
||||
}
|
||||
const sep = token.indexOf(KV);
|
||||
const path = token.slice(0, sep);
|
||||
map[path] = decodeLeaf(token.slice(sep + 1));
|
||||
});
|
||||
}
|
||||
return rebuildFromLeaves(map) as unknown as Query;
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { roundTripScenarios } from '../testing/scenarios';
|
||||
import { decodeFlatKeys, encodeFlatKeys, flatKeysAdapter } from './index';
|
||||
|
||||
const roundTrip = (query: Query): Query =>
|
||||
flatKeysAdapter.decode(flatKeysAdapter.encode(query));
|
||||
|
||||
const clone = (query: Query): Query =>
|
||||
JSON.parse(JSON.stringify(query)) as Query;
|
||||
|
||||
describe('flatKeysAdapter', () => {
|
||||
describe('round-trip scenarios', () => {
|
||||
it.each(roundTripScenarios)('$name', ({ query }) => {
|
||||
expect(roundTrip(query)).toStrictEqual(query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag matching', () => {
|
||||
it('tags output with FKm~/FKl~/FKt~', () => {
|
||||
const metricsEncoded = flatKeysAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(metricsEncoded.startsWith('FKm~')).toBe(true);
|
||||
|
||||
const logsEncoded = flatKeysAdapter.encode(initialQueriesMap.logs);
|
||||
expect(logsEncoded.startsWith('FKl~')).toBe(true);
|
||||
|
||||
const tracesEncoded = flatKeysAdapter.encode(initialQueriesMap.traces);
|
||||
expect(tracesEncoded.startsWith('FKt~')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches only own tags', () => {
|
||||
const encoded = flatKeysAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(flatKeysAdapter.matches(encoded)).toBe(true);
|
||||
expect(flatKeysAdapter.matches('FVm~')).toBe(false);
|
||||
expect(flatKeysAdapter.matches('%7Bnot-mine')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline behavior', () => {
|
||||
it('uses correct baseline tag per dataSource', () => {
|
||||
expect(encodeFlatKeys(initialQueriesMap.logs).tag).toBe('l');
|
||||
expect(encodeFlatKeys(initialQueriesMap.traces).tag).toBe('t');
|
||||
expect(encodeFlatKeys(initialQueriesMap.metrics).tag).toBe('m');
|
||||
});
|
||||
|
||||
it('decodeFlatKeys on empty payload returns baseline', () => {
|
||||
const decodedMetrics = decodeFlatKeys('', 'm');
|
||||
expect(decodedMetrics.queryType).toBe('builder');
|
||||
expect(decodedMetrics.builder.queryData[0].dataSource).toBe('metrics');
|
||||
|
||||
const decodedLogs = decodeFlatKeys('', 'l');
|
||||
expect(decodedLogs.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encoding stability', () => {
|
||||
it('identical encoding after roundtrip', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const encoded1 = flatKeysAdapter.encode(query);
|
||||
const decoded = flatKeysAdapter.decode(encoded1);
|
||||
const encoded2 = flatKeysAdapter.encode(decoded);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
|
||||
it('key order independent', () => {
|
||||
const query1 = initialQueriesMap.metrics;
|
||||
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
|
||||
|
||||
const reordered = {
|
||||
unit: query2.unit,
|
||||
id: query2.id,
|
||||
queryType: query2.queryType,
|
||||
clickhouse_sql: query2.clickhouse_sql,
|
||||
promql: query2.promql,
|
||||
builder: query2.builder,
|
||||
} as Query;
|
||||
|
||||
const encoded1 = flatKeysAdapter.encode(query1);
|
||||
const encoded2 = flatKeysAdapter.encode(reordered);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undefined handling', () => {
|
||||
it('handles undefined values without breaking decode', () => {
|
||||
const query = clone(initialQueriesMap.logs);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(query.builder.queryData[0] as any).aggregateOperator = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(query.builder.queryData[0] as any).source = undefined;
|
||||
|
||||
const encoded = flatKeysAdapter.encode(query);
|
||||
const decoded = flatKeysAdapter.decode(encoded);
|
||||
expect(decoded).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { BaselineTag } from 'lib/compositeQuery/baseline';
|
||||
import { decode, encode } from './codec';
|
||||
import { CompositeQueryAdapter } from './types';
|
||||
|
||||
const TAG_PREFIX = 'FK';
|
||||
const TAG_SUFFIX = '~';
|
||||
|
||||
/**
|
||||
* Flat-keys (FK~): stores a query as a keys-only, leaf-flattened diff from the
|
||||
* frozen baseline. Uses short keys but keeps values as raw JSON (no field-aware
|
||||
* enum compression).
|
||||
*
|
||||
* Useful for benchmarking: isolates contribution of key shortening from value compression.
|
||||
*/
|
||||
export const flatKeysAdapter: CompositeQueryAdapter = {
|
||||
name: 'flat-keys',
|
||||
encode: (query) => {
|
||||
const { payload, tag } = encode(query);
|
||||
return `${TAG_PREFIX}${tag}${TAG_SUFFIX}${payload}`;
|
||||
},
|
||||
matches: (raw) =>
|
||||
raw.startsWith(`${TAG_PREFIX}m${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}l${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}t${TAG_SUFFIX}`),
|
||||
decode: (raw) => {
|
||||
const tag = raw[2] as BaselineTag;
|
||||
const payload = raw.slice(4);
|
||||
return decode(payload, tag);
|
||||
},
|
||||
};
|
||||
|
||||
export { encode as encodeFlatKeys, decode as decodeFlatKeys } from './codec';
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Key shortening map for flatKeys adapter.
|
||||
* No value compression - only key shortening.
|
||||
*/
|
||||
export const KEY_MAP: Record<string, string> = {
|
||||
queryType: 'qt',
|
||||
builder: 'b',
|
||||
queryData: 'qd',
|
||||
queryFormulas: 'qf',
|
||||
queryTraceOperator: 'qo',
|
||||
dataSource: 'ds',
|
||||
queryName: 'qn',
|
||||
aggregateOperator: 'ao',
|
||||
aggregateAttribute: 'aa',
|
||||
timeAggregation: 'ta',
|
||||
spaceAggregation: 'sa',
|
||||
filter: 'f',
|
||||
expression: 'e',
|
||||
aggregations: 'ag',
|
||||
functions: 'fn',
|
||||
filters: 'fl',
|
||||
items: 'i',
|
||||
disabled: 'd',
|
||||
stepInterval: 'si',
|
||||
having: 'h',
|
||||
limit: 'l',
|
||||
orderBy: 'ob',
|
||||
groupBy: 'gb',
|
||||
legend: 'lg',
|
||||
reduceTo: 'rt',
|
||||
source: 's',
|
||||
promql: 'pq',
|
||||
clickhouse_sql: 'cs',
|
||||
name: 'n',
|
||||
query: 'q',
|
||||
key: 'k',
|
||||
dataType: 'dt',
|
||||
type: 't',
|
||||
metricName: 'mn',
|
||||
temporality: 'tp',
|
||||
columnName: 'cn',
|
||||
order: 'o',
|
||||
value: 'v',
|
||||
op: 'op',
|
||||
isColumn: 'ic',
|
||||
isJSON: 'ij',
|
||||
};
|
||||
|
||||
export const INVERSE_KEY_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(KEY_MAP).map(([long, short]) => [short, long]),
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* Flat-leaf codec: a baseline-diff serializer that emits one token per changed
|
||||
* scalar leaf, with no JSON wrapper characters on the wire.
|
||||
*
|
||||
* Both the baseline and the query are flattened to maps of
|
||||
* `{ shortDotPath: scalarLeaf }` (arrays walked element-wise, so `queryData[0]`
|
||||
* becomes `qd.0`). Encode diffs the two maps and emits a token per changed or
|
||||
* removed leaf; decode replays those tokens onto the baseline map and rebuilds
|
||||
* the nested object. Because arrays are walked element-wise, adding one filter
|
||||
* costs a few scalar tokens instead of a whole-array JSON blob.
|
||||
*
|
||||
* Wire grammar (tokens joined by `*`):
|
||||
* set: <shortPath>_<encodedLeaf> e.g. b.qd.0.ds_0
|
||||
* delete: -<shortPath> e.g. -b.qd.0.ag.0.mn
|
||||
*/
|
||||
import getBaselineByTag, {
|
||||
BaselineTag,
|
||||
pickBaseline,
|
||||
} from 'lib/compositeQuery/baseline';
|
||||
import {
|
||||
FIELD_DOMAINS,
|
||||
INVERSE_FIELD_DOMAINS,
|
||||
INVERSE_KEY_MAP,
|
||||
KEY_MAP,
|
||||
} from './maps';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
type Json = unknown;
|
||||
type PathSeg = string | number;
|
||||
|
||||
const PAIR = '*';
|
||||
const KV = '_';
|
||||
const DEL = '-';
|
||||
|
||||
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
|
||||
|
||||
const isContainer = (value: Json): value is Record<string, Json> | Json[] =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const isEmptyContainer = (value: Json): boolean =>
|
||||
isContainer(value) &&
|
||||
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
|
||||
|
||||
const isLeaf = (value: Json): boolean =>
|
||||
!isContainer(value) || isEmptyContainer(value);
|
||||
|
||||
const shortenSeg = (seg: PathSeg): PathSeg =>
|
||||
typeof seg === 'number' ? seg : (KEY_MAP[seg] ?? seg);
|
||||
|
||||
const lastSeg = (path: string): string => path.slice(path.lastIndexOf('.') + 1);
|
||||
|
||||
function leafMap(obj: Json): Record<string, Json> {
|
||||
const out: Record<string, Json> = {};
|
||||
const walk = (node: Json, segs: PathSeg[]): void => {
|
||||
if (isLeaf(node)) {
|
||||
out[segs.map(shortenSeg).join('.')] = node;
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((value, index) => walk(value, [...segs, index]));
|
||||
return;
|
||||
}
|
||||
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
|
||||
walk(value, [...segs, key]),
|
||||
);
|
||||
};
|
||||
walk(obj, []);
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeValue(str: string): string {
|
||||
let escaped = str.replace(/_/g, '__').replace(/\*/g, '_s');
|
||||
if (escaped[0] === '!') {
|
||||
escaped = `_i${escaped.slice(1)}`;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
const UNESCAPE: Record<string, string> = { _: '_', s: '*', i: '!' };
|
||||
|
||||
function unescapeValue(str: string): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i += 1) {
|
||||
if (str[i] === '_') {
|
||||
const next = str[i + 1];
|
||||
out += UNESCAPE[next] ?? next;
|
||||
i += 1;
|
||||
} else {
|
||||
out += str[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeLeaf(value: Json, shortKey: string): string {
|
||||
if (typeof value === 'string') {
|
||||
const domain = FIELD_DOMAINS[shortKey];
|
||||
if (domain?.[value] !== undefined) {
|
||||
return String(domain[value]);
|
||||
}
|
||||
return escapeValue(value);
|
||||
}
|
||||
if (value === undefined) {
|
||||
return '!null';
|
||||
}
|
||||
return `!${JSON.stringify(value)}`;
|
||||
}
|
||||
|
||||
function decodeLeaf(token: string, shortKey: string): Json {
|
||||
if (token[0] === '!') {
|
||||
return JSON.parse(token.slice(1));
|
||||
}
|
||||
const inverse = INVERSE_FIELD_DOMAINS[shortKey];
|
||||
if (inverse && isIndex(token)) {
|
||||
const mapped = inverse[Number(token)];
|
||||
if (mapped !== undefined) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
return unescapeValue(token);
|
||||
}
|
||||
|
||||
function parsePath(pathStr: string): PathSeg[] {
|
||||
return pathStr
|
||||
.split('.')
|
||||
.map((seg) => (isIndex(seg) ? Number(seg) : (INVERSE_KEY_MAP[seg] ?? seg)));
|
||||
}
|
||||
|
||||
function setPath(
|
||||
root: Record<string, Json>,
|
||||
segs: PathSeg[],
|
||||
value: Json,
|
||||
): void {
|
||||
let node: Json = root;
|
||||
for (let i = 0; i < segs.length - 1; i += 1) {
|
||||
const seg = segs[i];
|
||||
const container = node as Record<string | number, Json>;
|
||||
if (!isContainer(container[seg])) {
|
||||
container[seg] = typeof segs[i + 1] === 'number' ? [] : {};
|
||||
}
|
||||
node = container[seg];
|
||||
}
|
||||
(node as Record<string | number, Json>)[segs[segs.length - 1]] = value;
|
||||
}
|
||||
|
||||
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
|
||||
const root: Record<string, Json> = {};
|
||||
Object.entries(map).forEach(([path, value]) => {
|
||||
setPath(root, parsePath(path), value);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
export function encode(query: Query): { payload: string; tag: BaselineTag } {
|
||||
const { baseline, tag } = pickBaseline(query);
|
||||
const base = leafMap(baseline);
|
||||
const next = leafMap(query);
|
||||
const tokens: string[] = [];
|
||||
Object.entries(next).forEach(([path, value]) => {
|
||||
const baseVal = base[path];
|
||||
const normalizedBase = baseVal === undefined ? null : baseVal;
|
||||
const normalizedNext = value === undefined ? null : value;
|
||||
if (
|
||||
path in base &&
|
||||
JSON.stringify(normalizedBase) === JSON.stringify(normalizedNext)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
tokens.push(`${path}${KV}${encodeLeaf(value, lastSeg(path))}`);
|
||||
});
|
||||
Object.keys(base).forEach((path) => {
|
||||
if (!(path in next)) {
|
||||
tokens.push(`${DEL}${path}`);
|
||||
}
|
||||
});
|
||||
tokens.sort();
|
||||
return { payload: tokens.join(PAIR), tag };
|
||||
}
|
||||
|
||||
export function decode(payload: string, tag: BaselineTag): Query {
|
||||
const map = leafMap(getBaselineByTag(tag));
|
||||
if (payload) {
|
||||
payload.split(PAIR).forEach((token) => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (token[0] === DEL) {
|
||||
delete map[token.slice(1)];
|
||||
return;
|
||||
}
|
||||
const sep = token.indexOf(KV);
|
||||
const path = token.slice(0, sep);
|
||||
map[path] = decodeLeaf(token.slice(sep + 1), lastSeg(path));
|
||||
});
|
||||
}
|
||||
return rebuildFromLeaves(map) as unknown as Query;
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { roundTripScenarios } from '../testing/scenarios';
|
||||
import { decodeFlatLeaf, encodeFlatLeaf, flatLeafAdapter } from './index';
|
||||
|
||||
const roundTrip = (query: Query): Query =>
|
||||
flatLeafAdapter.decode(flatLeafAdapter.encode(query));
|
||||
|
||||
const clone = (query: Query): Query =>
|
||||
JSON.parse(JSON.stringify(query)) as Query;
|
||||
|
||||
describe('flatLeafAdapter', () => {
|
||||
describe('round-trip scenarios', () => {
|
||||
it.each(roundTripScenarios)('$name', ({ query }) => {
|
||||
expect(roundTrip(query)).toStrictEqual(query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag matching', () => {
|
||||
it('tags output with FVm~/FVl~/FVt~', () => {
|
||||
const metricsEncoded = flatLeafAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(metricsEncoded.startsWith('FVm~')).toBe(true);
|
||||
|
||||
const logsEncoded = flatLeafAdapter.encode(initialQueriesMap.logs);
|
||||
expect(logsEncoded.startsWith('FVl~')).toBe(true);
|
||||
|
||||
const tracesEncoded = flatLeafAdapter.encode(initialQueriesMap.traces);
|
||||
expect(tracesEncoded.startsWith('FVt~')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches only own tags', () => {
|
||||
const encoded = flatLeafAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(flatLeafAdapter.matches(encoded)).toBe(true);
|
||||
expect(flatLeafAdapter.matches('V1m~[]')).toBe(false);
|
||||
expect(flatLeafAdapter.matches('%7Bnot-mine')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline behavior', () => {
|
||||
it('uses correct baseline tag per dataSource', () => {
|
||||
expect(encodeFlatLeaf(initialQueriesMap.logs).tag).toBe('l');
|
||||
expect(encodeFlatLeaf(initialQueriesMap.traces).tag).toBe('t');
|
||||
expect(encodeFlatLeaf(initialQueriesMap.metrics).tag).toBe('m');
|
||||
});
|
||||
|
||||
it('emits no payload when query equals baseline shape', () => {
|
||||
const { payload, tag } = encodeFlatLeaf(initialQueriesMap.logs);
|
||||
expect(tag).toBe('l');
|
||||
expect(decodeFlatLeaf(payload, tag)).toStrictEqual(initialQueriesMap.logs);
|
||||
});
|
||||
|
||||
it('decodeFlatLeaf on empty payload returns baseline', () => {
|
||||
const decodedMetrics = decodeFlatLeaf('', 'm');
|
||||
expect(decodedMetrics.queryType).toBe('builder');
|
||||
expect(decodedMetrics.builder.queryData[0].dataSource).toBe('metrics');
|
||||
|
||||
const decodedLogs = decodeFlatLeaf('', 'l');
|
||||
expect(decodedLogs.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encoding stability', () => {
|
||||
it('identical encoding after roundtrip', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const encoded1 = flatLeafAdapter.encode(query);
|
||||
const decoded = flatLeafAdapter.decode(encoded1);
|
||||
const encoded2 = flatLeafAdapter.encode(decoded);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
|
||||
it('key order independent', () => {
|
||||
const query1 = initialQueriesMap.metrics;
|
||||
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
|
||||
|
||||
const reordered = {
|
||||
unit: query2.unit,
|
||||
id: query2.id,
|
||||
queryType: query2.queryType,
|
||||
clickhouse_sql: query2.clickhouse_sql,
|
||||
promql: query2.promql,
|
||||
builder: query2.builder,
|
||||
} as Query;
|
||||
|
||||
const encoded1 = flatLeafAdapter.encode(query1);
|
||||
const encoded2 = flatLeafAdapter.encode(reordered);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
|
||||
it('stable after spread/reconstruct', () => {
|
||||
const query = { ...initialQueriesMap.metrics };
|
||||
const encoded1 = flatLeafAdapter.encode(query);
|
||||
|
||||
const transformed = {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const encoded2 = flatLeafAdapter.encode(transformed);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('key validation', () => {
|
||||
it('decoded query has all keys expected by replaceIncorrectObjectFields', () => {
|
||||
const decoded = flatLeafAdapter.decode(
|
||||
flatLeafAdapter.encode(initialQueriesMap.metrics),
|
||||
);
|
||||
const decodedKeys = Object.keys(decoded).sort();
|
||||
const expectedKeys = Object.keys(initialQueriesMap.metrics).sort();
|
||||
|
||||
expect(decodedKeys).toStrictEqual(expectedKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEqual compatibility', () => {
|
||||
it('lodash isEqual works for decoded queries', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const encoded = flatLeafAdapter.encode(query);
|
||||
const decoded = flatLeafAdapter.decode(encoded);
|
||||
|
||||
const { id: _id1, ...rest1 } = query;
|
||||
const { id: _id2, ...rest2 } = decoded;
|
||||
|
||||
expect(isEqual(rest1, rest2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undefined handling', () => {
|
||||
it('handles undefined values without breaking decode', () => {
|
||||
const query = clone(initialQueriesMap.logs);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(query.builder.queryData[0] as any).aggregateOperator = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(query.builder.queryData[0] as any).source = undefined;
|
||||
|
||||
const encoded = flatLeafAdapter.encode(query);
|
||||
const decoded = flatLeafAdapter.decode(encoded);
|
||||
expect(decoded).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { BaselineTag } from 'lib/compositeQuery/baseline';
|
||||
import { decode, encode } from './codec';
|
||||
import { CompositeQueryAdapter } from './types';
|
||||
|
||||
const TAG_PREFIX = 'FV';
|
||||
const TAG_SUFFIX = '~';
|
||||
|
||||
/**
|
||||
* Flat-leaf (FV~): stores a query as a field-aware, leaf-flattened diff from the
|
||||
* frozen baseline. Uses the smart hybrid strategy — metrics or logs baseline
|
||||
* picked by dataSource — so the tag is FVm~ (metrics) or FVl~ (logs).
|
||||
*/
|
||||
export const flatLeafAdapter: CompositeQueryAdapter = {
|
||||
name: 'flat-leaf',
|
||||
encode: (query) => {
|
||||
const { payload, tag } = encode(query);
|
||||
return `${TAG_PREFIX}${tag}${TAG_SUFFIX}${payload}`;
|
||||
},
|
||||
matches: (raw) =>
|
||||
raw.startsWith(`${TAG_PREFIX}m${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}l${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}t${TAG_SUFFIX}`),
|
||||
decode: (raw) => {
|
||||
const tag = raw[2] as BaselineTag;
|
||||
const payload = raw.slice(4);
|
||||
return decode(payload, tag);
|
||||
},
|
||||
};
|
||||
|
||||
export { encode as encodeFlatLeaf, decode as decodeFlatLeaf } from './codec';
|
||||
@@ -1,197 +0,0 @@
|
||||
import {
|
||||
MetrictypesSpaceAggregationDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTimeAggregationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AutocompleteType,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import {
|
||||
DataSource,
|
||||
LogsAggregatorOperator,
|
||||
MetricAggregateOperator,
|
||||
ReduceOperators,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
export const KEY_MAP: Record<string, string> = {
|
||||
queryType: 'qt',
|
||||
builder: 'b',
|
||||
queryData: 'qd',
|
||||
queryFormulas: 'qf',
|
||||
queryTraceOperator: 'qo',
|
||||
dataSource: 'ds',
|
||||
queryName: 'qn',
|
||||
aggregateOperator: 'ao',
|
||||
aggregateAttribute: 'aa',
|
||||
timeAggregation: 'ta',
|
||||
spaceAggregation: 'sa',
|
||||
filter: 'f',
|
||||
expression: 'e',
|
||||
aggregations: 'ag',
|
||||
functions: 'fn',
|
||||
filters: 'fl',
|
||||
items: 'i',
|
||||
disabled: 'd',
|
||||
stepInterval: 'si',
|
||||
having: 'h',
|
||||
limit: 'l',
|
||||
orderBy: 'ob',
|
||||
groupBy: 'gb',
|
||||
legend: 'lg',
|
||||
reduceTo: 'rt',
|
||||
source: 's',
|
||||
promql: 'pq',
|
||||
clickhouse_sql: 'cs',
|
||||
name: 'n',
|
||||
query: 'q',
|
||||
key: 'k',
|
||||
dataType: 'dt',
|
||||
type: 't',
|
||||
metricName: 'mn',
|
||||
temporality: 'tp',
|
||||
columnName: 'cn',
|
||||
order: 'o',
|
||||
value: 'v',
|
||||
op: 'op',
|
||||
isColumn: 'ic',
|
||||
isJSON: 'ij',
|
||||
};
|
||||
|
||||
export const INVERSE_KEY_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(KEY_MAP).map(([long, short]) => [short, long]),
|
||||
);
|
||||
|
||||
const DATA_SOURCE: Record<DataSource, number> = {
|
||||
[DataSource.LOGS]: 0,
|
||||
[DataSource.METRICS]: 1,
|
||||
[DataSource.TRACES]: 2,
|
||||
};
|
||||
|
||||
const QUERY_TYPE: Record<EQueryType, number> = {
|
||||
[EQueryType.QUERY_BUILDER]: 0,
|
||||
[EQueryType.PROM]: 1,
|
||||
[EQueryType.CLICKHOUSE]: 2,
|
||||
};
|
||||
|
||||
const REDUCE_TO: Record<ReduceOperators, number> = {
|
||||
[ReduceOperators.LAST]: 0,
|
||||
[ReduceOperators.SUM]: 1,
|
||||
[ReduceOperators.AVG]: 2,
|
||||
[ReduceOperators.MAX]: 3,
|
||||
[ReduceOperators.MIN]: 4,
|
||||
};
|
||||
|
||||
const TEMPORALITY: Record<MetrictypesTemporalityDTO, number> = {
|
||||
[MetrictypesTemporalityDTO.delta]: 0,
|
||||
[MetrictypesTemporalityDTO.cumulative]: 1,
|
||||
[MetrictypesTemporalityDTO.unspecified]: 2,
|
||||
};
|
||||
|
||||
const TIME_AGGREGATION: Record<MetrictypesTimeAggregationDTO, number> = {
|
||||
[MetrictypesTimeAggregationDTO.latest]: 0,
|
||||
[MetrictypesTimeAggregationDTO.sum]: 1,
|
||||
[MetrictypesTimeAggregationDTO.avg]: 2,
|
||||
[MetrictypesTimeAggregationDTO.min]: 3,
|
||||
[MetrictypesTimeAggregationDTO.max]: 4,
|
||||
[MetrictypesTimeAggregationDTO.count]: 5,
|
||||
[MetrictypesTimeAggregationDTO.count_distinct]: 6,
|
||||
[MetrictypesTimeAggregationDTO.rate]: 7,
|
||||
[MetrictypesTimeAggregationDTO.increase]: 8,
|
||||
};
|
||||
|
||||
const SPACE_AGGREGATION: Record<MetrictypesSpaceAggregationDTO, number> = {
|
||||
[MetrictypesSpaceAggregationDTO.sum]: 0,
|
||||
[MetrictypesSpaceAggregationDTO.avg]: 1,
|
||||
[MetrictypesSpaceAggregationDTO.min]: 2,
|
||||
[MetrictypesSpaceAggregationDTO.max]: 3,
|
||||
[MetrictypesSpaceAggregationDTO.count]: 4,
|
||||
[MetrictypesSpaceAggregationDTO.p50]: 5,
|
||||
[MetrictypesSpaceAggregationDTO.p75]: 6,
|
||||
[MetrictypesSpaceAggregationDTO.p90]: 7,
|
||||
[MetrictypesSpaceAggregationDTO.p95]: 8,
|
||||
[MetrictypesSpaceAggregationDTO.p99]: 9,
|
||||
};
|
||||
|
||||
const AGGREGATE_OPERATOR: Record<
|
||||
MetricAggregateOperator | TracesAggregatorOperator | LogsAggregatorOperator,
|
||||
number
|
||||
> = {
|
||||
[MetricAggregateOperator.EMPTY]: 0,
|
||||
[MetricAggregateOperator.NOOP]: 1,
|
||||
[MetricAggregateOperator.COUNT]: 2,
|
||||
[MetricAggregateOperator.COUNT_DISTINCT]: 3,
|
||||
[MetricAggregateOperator.SUM]: 4,
|
||||
[MetricAggregateOperator.AVG]: 5,
|
||||
[MetricAggregateOperator.MAX]: 6,
|
||||
[MetricAggregateOperator.MIN]: 7,
|
||||
[MetricAggregateOperator.P05]: 8,
|
||||
[MetricAggregateOperator.P10]: 9,
|
||||
[MetricAggregateOperator.P20]: 10,
|
||||
[MetricAggregateOperator.P25]: 11,
|
||||
[MetricAggregateOperator.P50]: 12,
|
||||
[MetricAggregateOperator.P75]: 13,
|
||||
[MetricAggregateOperator.P90]: 14,
|
||||
[MetricAggregateOperator.P95]: 15,
|
||||
[MetricAggregateOperator.P99]: 16,
|
||||
[MetricAggregateOperator.RATE]: 17,
|
||||
[MetricAggregateOperator.SUM_RATE]: 18,
|
||||
[MetricAggregateOperator.AVG_RATE]: 19,
|
||||
[MetricAggregateOperator.MAX_RATE]: 20,
|
||||
[MetricAggregateOperator.MIN_RATE]: 21,
|
||||
[MetricAggregateOperator.RATE_SUM]: 22,
|
||||
[MetricAggregateOperator.RATE_AVG]: 23,
|
||||
[MetricAggregateOperator.RATE_MIN]: 24,
|
||||
[MetricAggregateOperator.RATE_MAX]: 25,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_50]: 26,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_75]: 27,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_90]: 28,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_95]: 29,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_99]: 30,
|
||||
[MetricAggregateOperator.INCREASE]: 31,
|
||||
[MetricAggregateOperator.LATEST]: 32,
|
||||
};
|
||||
|
||||
const DATA_TYPE: Record<DataTypes, number> = {
|
||||
[DataTypes.Int64]: 0,
|
||||
[DataTypes.String]: 1,
|
||||
[DataTypes.Float64]: 2,
|
||||
[DataTypes.bool]: 3,
|
||||
[DataTypes.ArrayFloat64]: 4,
|
||||
[DataTypes.ArrayInt64]: 5,
|
||||
[DataTypes.ArrayString]: 6,
|
||||
[DataTypes.ArrayBool]: 7,
|
||||
[DataTypes.EMPTY]: 8,
|
||||
};
|
||||
|
||||
const ATTR_TYPE: Record<AutocompleteType, number> = {
|
||||
tag: 0,
|
||||
resource: 1,
|
||||
'': 2,
|
||||
};
|
||||
|
||||
export const FIELD_DOMAINS: Record<string, Record<string, number>> = {
|
||||
ds: DATA_SOURCE,
|
||||
qt: QUERY_TYPE,
|
||||
rt: REDUCE_TO,
|
||||
tp: TEMPORALITY,
|
||||
ta: TIME_AGGREGATION,
|
||||
sa: SPACE_AGGREGATION,
|
||||
ao: AGGREGATE_OPERATOR,
|
||||
dt: DATA_TYPE,
|
||||
t: ATTR_TYPE,
|
||||
};
|
||||
|
||||
export const INVERSE_FIELD_DOMAINS: Record<
|
||||
string,
|
||||
Record<number, string>
|
||||
> = Object.fromEntries(
|
||||
Object.entries(FIELD_DOMAINS).map(([field, domain]) => [
|
||||
field,
|
||||
Object.fromEntries(
|
||||
Object.entries(domain).map(([str, int]) => [int, str]),
|
||||
) as Record<number, string>,
|
||||
]),
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
convertHavingToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { CompositeQueryAdapter } from './types';
|
||||
|
||||
function migrateLegacyFormat(parsed: Query): Query {
|
||||
if (!parsed?.builder?.queryData) {
|
||||
return parsed;
|
||||
}
|
||||
const next = parsed;
|
||||
next.builder.queryData = parsed.builder.queryData.map((query) => {
|
||||
const existingExpression = query.filter?.expression || '';
|
||||
const convertedQuery = { ...query };
|
||||
|
||||
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
|
||||
query.filters || { items: [], op: 'AND' },
|
||||
existingExpression,
|
||||
);
|
||||
convertedQuery.filter = convertedFilter.filter;
|
||||
convertedQuery.filters = convertedFilter.filters;
|
||||
|
||||
if (Array.isArray(query.having)) {
|
||||
convertedQuery.having = convertHavingToExpression(query.having);
|
||||
}
|
||||
|
||||
if (!query.aggregations && query.aggregateOperator) {
|
||||
convertedQuery.aggregations = convertAggregationToExpression({
|
||||
aggregateOperator: query.aggregateOperator,
|
||||
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
|
||||
dataSource: query.dataSource,
|
||||
timeAggregation: query.timeAggregation,
|
||||
spaceAggregation: query.spaceAggregation,
|
||||
reduceTo: query.reduceTo,
|
||||
temporality: query.temporality,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
}
|
||||
return convertedQuery;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export const jsonAdapter: CompositeQueryAdapter = {
|
||||
name: 'json(legacy)',
|
||||
encode: (query) => encodeURIComponent(JSON.stringify(query)),
|
||||
matches: () => true,
|
||||
decode: (raw) => {
|
||||
const parsed: Query = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
|
||||
return migrateLegacyFormat(parsed);
|
||||
},
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { jsonAdapter } from './index';
|
||||
|
||||
const roundTrip = (query: Query): Query =>
|
||||
jsonAdapter.decode(jsonAdapter.encode(query));
|
||||
|
||||
describe('jsonAdapter', () => {
|
||||
describe('round-trip', () => {
|
||||
it.each(['metrics', 'logs', 'traces'] as const)(
|
||||
'round-trips %s baseline preserving dataSource',
|
||||
(source) => {
|
||||
const query = initialQueriesMap[source];
|
||||
const decoded = roundTrip(query);
|
||||
expect(decoded.builder.queryData[0].dataSource).toBe(source);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('legacy format compatibility', () => {
|
||||
it('encodes to legacy format (encodeURIComponent + JSON)', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
const encoded = jsonAdapter.encode(query);
|
||||
|
||||
expect(encoded).toBe(encodeURIComponent(JSON.stringify(query)));
|
||||
expect(encoded.startsWith('%7B')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag matching', () => {
|
||||
it('matches any value (catch-all fallback)', () => {
|
||||
expect(jsonAdapter.matches('%7B%22queryType%22%3A%22builder%22%7D')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(jsonAdapter.matches('z1~abc')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration', () => {
|
||||
it('migrates old format (filters -> filter.expression)', () => {
|
||||
const legacy = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { op: 'AND', items: [] },
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: '', dataType: '', type: '' },
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'x',
|
||||
unit: '',
|
||||
};
|
||||
const raw = encodeURIComponent(JSON.stringify(legacy));
|
||||
const decoded = jsonAdapter.decode(raw);
|
||||
expect(decoded.builder.queryData[0].filter).toBeDefined();
|
||||
expect(decoded.builder.queryData[0].aggregations).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export interface RoundTripScenario {
|
||||
name: string;
|
||||
query: Query;
|
||||
}
|
||||
|
||||
const clone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)) as T;
|
||||
|
||||
const makePromqlQuery = (): Query => {
|
||||
const query = clone(initialQueriesMap.metrics);
|
||||
query.queryType = EQueryType.PROM;
|
||||
query.promql[0].query = 'rate(http_requests_total[5m])';
|
||||
return query;
|
||||
};
|
||||
|
||||
const makeClickhouseQuery = (): Query => {
|
||||
const query = clone(initialQueriesMap.metrics);
|
||||
query.queryType = EQueryType.CLICKHOUSE;
|
||||
query.clickhouse_sql[0].query = 'SELECT count() FROM signoz_logs';
|
||||
return query;
|
||||
};
|
||||
|
||||
const makeModifiedBuilderQuery = (): Query => {
|
||||
const query = clone(initialQueriesMap.logs);
|
||||
const qd = query.builder.queryData[0];
|
||||
qd.aggregateOperator = 'p95';
|
||||
qd.disabled = true;
|
||||
qd.stepInterval = 60;
|
||||
qd.legend = 'error rate';
|
||||
qd.filter = { expression: "severity_text = 'ERROR'" };
|
||||
qd.filters = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
key: {
|
||||
key: 'severity_text',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
id: 'item-1',
|
||||
op: '=',
|
||||
value: 'ERROR',
|
||||
},
|
||||
],
|
||||
};
|
||||
qd.orderBy = [{ columnName: 'timestamp', order: 'desc' }];
|
||||
return query;
|
||||
};
|
||||
|
||||
const makeQueryWithCustomId = (): Query => ({
|
||||
...initialQueriesMap.metrics,
|
||||
id: 'test-query-uuid-123',
|
||||
});
|
||||
|
||||
const makeQueryWithEnumLikeLegend = (): Query => {
|
||||
const query = clone(initialQueriesMap.metrics);
|
||||
query.builder.queryData[0].legend = 'sum';
|
||||
query.id = 'my-query-id';
|
||||
return query;
|
||||
};
|
||||
|
||||
const makeQueryWithWireDelimiters = (): Query => {
|
||||
const query = clone(initialQueriesMap.logs);
|
||||
query.builder.queryData[0].legend = '_a*b_*c';
|
||||
query.builder.queryData[0].filter = { expression: '!weird = "x_y*z"' };
|
||||
return query;
|
||||
};
|
||||
|
||||
export const roundTripScenarios: RoundTripScenario[] = [
|
||||
{ name: 'metrics baseline', query: initialQueriesMap.metrics },
|
||||
{ name: 'logs baseline', query: initialQueriesMap.logs },
|
||||
{ name: 'traces baseline', query: initialQueriesMap.traces },
|
||||
{ name: 'promql query', query: makePromqlQuery() },
|
||||
{ name: 'clickhouse query', query: makeClickhouseQuery() },
|
||||
{ name: 'modified builder query', query: makeModifiedBuilderQuery() },
|
||||
{ name: 'custom id', query: makeQueryWithCustomId() },
|
||||
{ name: 'enum-like legend preserved', query: makeQueryWithEnumLikeLegend() },
|
||||
{ name: 'wire delimiters in values', query: makeQueryWithWireDelimiters() },
|
||||
];
|
||||
@@ -1,179 +0,0 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
|
||||
* only the diff from the chosen baseline; decode replays it onto a clone. These
|
||||
* MUST stay byte-stable forever — changing them silently invalidates every URL
|
||||
* already emitted against the old baseline. To evolve the schema, add NEW
|
||||
* tagged adapters (V2~) with their own baselines rather than editing these.
|
||||
*
|
||||
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
|
||||
* entries (nothing is stripped before diffing).
|
||||
*/
|
||||
|
||||
/** Baseline for metrics queries — uses metric-style aggregations object. */
|
||||
export const METRICS_BASELINE = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'metrics',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '----',
|
||||
key: '',
|
||||
dataType: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
filter: { expression: '' },
|
||||
aggregations: [
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: 'avg',
|
||||
},
|
||||
],
|
||||
functions: [],
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: '',
|
||||
unit: '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any as Query;
|
||||
|
||||
/** Baseline for logs/traces queries — uses expression-style aggregations. */
|
||||
export const LOGS_BASELINE = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: null,
|
||||
aggregateAttribute: {
|
||||
id: '----',
|
||||
key: '',
|
||||
dataType: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
filter: { expression: '' },
|
||||
aggregations: [{ expression: 'count() ' }],
|
||||
functions: [],
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: '',
|
||||
unit: '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any as Query;
|
||||
|
||||
/** Baseline for traces queries — same as logs but with dataSource: traces. */
|
||||
export const TRACES_BASELINE = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'traces',
|
||||
queryName: 'A',
|
||||
aggregateOperator: null,
|
||||
aggregateAttribute: {
|
||||
id: '----',
|
||||
key: '',
|
||||
dataType: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
filter: { expression: '' },
|
||||
aggregations: [{ expression: 'count() ' }],
|
||||
functions: [],
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: '',
|
||||
unit: '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any as Query;
|
||||
|
||||
/** Baseline tag indicators for URL encoding. */
|
||||
export type BaselineTag = 'm' | 'l' | 't';
|
||||
|
||||
/** Pick optimal baseline based on query's primary dataSource. */
|
||||
export function pickBaseline(query: Query): {
|
||||
baseline: Query;
|
||||
tag: BaselineTag;
|
||||
} {
|
||||
const ds = query.builder?.queryData?.[0]?.dataSource;
|
||||
if (ds === 'logs') {
|
||||
return { baseline: LOGS_BASELINE, tag: 'l' };
|
||||
}
|
||||
if (ds === 'traces') {
|
||||
return { baseline: TRACES_BASELINE, tag: 't' };
|
||||
}
|
||||
return { baseline: METRICS_BASELINE, tag: 'm' };
|
||||
}
|
||||
|
||||
function getBaselineByTag(tag: BaselineTag): Query {
|
||||
if (tag === 'l') {
|
||||
return LOGS_BASELINE;
|
||||
}
|
||||
if (tag === 't') {
|
||||
return TRACES_BASELINE;
|
||||
}
|
||||
return METRICS_BASELINE;
|
||||
}
|
||||
|
||||
export default getBaselineByTag;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
|
||||
import { CompositeQueryAdapter } from 'lib/compositeQuery/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { flatKeysAdapter } from 'lib/compositeQuery/adapters/flatKeys';
|
||||
|
||||
// Order matters for decode: most-specific (tagged) adapters first, json last
|
||||
const ADAPTERS: CompositeQueryAdapter[] = [flatKeysAdapter, jsonAdapter];
|
||||
|
||||
/** Encode a query to the shortest available URL value. */
|
||||
export function serialize(query: Query): string {
|
||||
return ADAPTERS.map((adapter) => adapter.encode(query)).reduce(
|
||||
(shortest, candidate) =>
|
||||
candidate.length < shortest.length ? candidate : shortest,
|
||||
);
|
||||
}
|
||||
|
||||
/** Decode a URL value back to a Query. Total: returns null on any failure. */
|
||||
export function deserialize(raw: string): Query | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const adapter = ADAPTERS.find((item) => item.matches(raw)) ?? jsonAdapter;
|
||||
return adapter.decode(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* A serialization tier. `encode` returns the COMPLETE URL value (tag prefix
|
||||
* included for tagged tiers). `matches` decides whether a raw value belongs to
|
||||
* this adapter on decode. `decode` receives the COMPLETE raw value and strips
|
||||
* its own tag.
|
||||
*/
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
@@ -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..." />;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
.settings-page {
|
||||
max-height: 100vh;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.settings-page-header {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
backdrop-filter: blur(20px);
|
||||
@@ -24,13 +28,14 @@
|
||||
}
|
||||
|
||||
.settings-page-content-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
|
||||
.settings-page-sidenav {
|
||||
width: 240px;
|
||||
height: calc(100vh - 48px);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
padding-top: var(--padding-1);
|
||||
@@ -74,7 +79,6 @@
|
||||
|
||||
.settings-page-content {
|
||||
flex: 1;
|
||||
height: calc(100vh - 48px);
|
||||
background: var(--l1-background);
|
||||
padding: 10px 8px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -38,7 +38,6 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
|
||||
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
|
||||
@@ -937,7 +936,7 @@ export function QueryBuilderProvider({
|
||||
);
|
||||
|
||||
const { safeNavigate } = useSafeNavigate({
|
||||
preventSameUrlNavigation: true,
|
||||
preventSameUrlNavigation: false,
|
||||
});
|
||||
|
||||
const redirectWithQueryBuilderData = useCallback(
|
||||
@@ -991,7 +990,10 @@ export function QueryBuilderProvider({
|
||||
);
|
||||
}
|
||||
|
||||
urlQuery.set(QueryParams.compositeQuery, serialize(currentGeneratedQuery));
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),
|
||||
);
|
||||
|
||||
if (searchParams) {
|
||||
Object.keys(searchParams).forEach((param) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ package sqlalertmanagerstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
@@ -39,16 +40,20 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gettablePlannedMaintenance := make([]*alertmanagertypes.PlannedMaintenance, 0)
|
||||
plannedMaintenances := make([]*alertmanagertypes.PlannedMaintenance, 0, len(gettableMaintenancesRules))
|
||||
for _, gettableMaintenancesRule := range gettableMaintenancesRules {
|
||||
m := gettableMaintenancesRule.ToPlannedMaintenance()
|
||||
gettablePlannedMaintenance = append(gettablePlannedMaintenance, m)
|
||||
if m.HasScheduleRecurrenceBoundsMismatch() {
|
||||
r.logger.WarnContext(ctx, "planned_downtime_recurrence_schedule_mismatch", slog.String("maintenance_id", m.ID.StringValue()))
|
||||
pm, err := gettableMaintenancesRule.ToPlannedMaintenance()
|
||||
if err != nil {
|
||||
// Don't return an error because we want to process all the valid records.
|
||||
// Log and skip instead.
|
||||
r.logger.WarnContext(ctx, "skipping planned maintenance", slog.String("maintenance_id", gettableMaintenancesRule.ID.StringValue()), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
plannedMaintenances = append(plannedMaintenances, pm)
|
||||
}
|
||||
|
||||
return gettablePlannedMaintenance, nil
|
||||
return plannedMaintenances, nil
|
||||
}
|
||||
|
||||
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
@@ -64,7 +69,7 @@ func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.U
|
||||
return nil, r.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "planned maintenance with ID: %s does not exist", id.StringValue())
|
||||
}
|
||||
|
||||
return storableMaintenanceRule.ToPlannedMaintenance(), nil
|
||||
return storableMaintenanceRule.ToPlannedMaintenance()
|
||||
}
|
||||
|
||||
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
@@ -73,6 +78,11 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return nil, err
|
||||
}
|
||||
|
||||
schedule, err := json.Marshal(maintenance.Schedule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
@@ -87,7 +97,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
},
|
||||
Name: maintenance.Name,
|
||||
Description: maintenance.Description,
|
||||
Schedule: maintenance.Schedule,
|
||||
Schedule: string(schedule),
|
||||
OrgID: claims.OrgID,
|
||||
Scope: maintenance.Scope,
|
||||
}
|
||||
@@ -135,18 +145,21 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &alertmanagertypes.PlannedMaintenance{
|
||||
pm := &alertmanagertypes.PlannedMaintenance{
|
||||
ID: storablePlannedMaintenance.ID,
|
||||
Name: storablePlannedMaintenance.Name,
|
||||
Description: storablePlannedMaintenance.Description,
|
||||
Schedule: storablePlannedMaintenance.Schedule,
|
||||
RuleIDs: maintenance.AlertIds,
|
||||
Scope: maintenance.Scope,
|
||||
CreatedAt: storablePlannedMaintenance.CreatedAt,
|
||||
CreatedBy: storablePlannedMaintenance.CreatedBy,
|
||||
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
|
||||
UpdatedBy: storablePlannedMaintenance.UpdatedBy,
|
||||
}, nil
|
||||
}
|
||||
if err = json.Unmarshal([]byte(storablePlannedMaintenance.Schedule), &pm.Schedule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pm, nil
|
||||
}
|
||||
|
||||
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
|
||||
@@ -174,6 +187,11 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return err
|
||||
}
|
||||
|
||||
schedule, err := json.Marshal(maintenance.Schedule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: id,
|
||||
@@ -188,7 +206,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
},
|
||||
Name: maintenance.Name,
|
||||
Description: maintenance.Description,
|
||||
Schedule: maintenance.Schedule,
|
||||
Schedule: string(schedule),
|
||||
OrgID: claims.OrgID,
|
||||
Scope: maintenance.Scope,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package sqlalertmanagerstore
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
store, err := sqlitesqlstore.New(t.Context(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{
|
||||
MaxOpenConns: 1,
|
||||
MaxConnLifetime: 0,
|
||||
},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: filepath.Join(t.TempDir(), "test.db"),
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*alertmanagertypes.StorablePlannedMaintenance)(nil)).
|
||||
IfNotExists().
|
||||
Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*alertmanagertypes.StorablePlannedMaintenanceRule)(nil)).
|
||||
IfNotExists().
|
||||
Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// TestListPlannedMaintenanceSkipsInvalid asserts that a single corrupt record
|
||||
// (here, an unloadable timezone) is skipped rather than failing the whole list.
|
||||
func TestListPlannedMaintenanceSkipsInvalid(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
now := time.Now().UTC()
|
||||
|
||||
valid := &alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
Name: "valid",
|
||||
Schedule: `{"timezone":"UTC","startTime":"2024-01-01T12:00:00Z","recurrence":{"duration":"2h","repeatType":"daily"}}`,
|
||||
OrgID: orgID,
|
||||
}
|
||||
result, err := store.BunDB().NewInsert().Model(valid).Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAffected)
|
||||
|
||||
// A schedule with "zero" startTime
|
||||
invalid := &alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
Name: "invalid",
|
||||
Schedule: `{"timezone":"UTC","recurrence":{"duration":"2h","repeatType":"daily"}}`,
|
||||
OrgID: orgID,
|
||||
}
|
||||
result, err = store.BunDB().NewInsert().Model(invalid).Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
rowsAffected, err = result.RowsAffected()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAffected)
|
||||
|
||||
maintenanceStore := NewMaintenanceStore(store, factorytest.NewSettings())
|
||||
|
||||
list, err := maintenanceStore.ListPlannedMaintenance(t.Context(), orgID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, valid.ID, list[0].ID)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package alertmanager
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,7 +16,23 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const prefix = "SIGNOZ_"
|
||||
|
||||
// clearSignozEnv unsets all existing SIGNOZ_* env vars for the duration of the test.
|
||||
func clearSignozEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, kv := range os.Environ() {
|
||||
if strings.HasPrefix(kv, prefix) {
|
||||
key := strings.SplitN(kv, "=", 2)[0]
|
||||
orig, _ := os.LookupEnv(key)
|
||||
_ = os.Unsetenv(key)
|
||||
t.Cleanup(func() { _ = os.Setenv(key, orig) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithEnvProvider(t *testing.T) {
|
||||
clearSignozEnv(t)
|
||||
t.Setenv("SIGNOZ_ALERTMANAGER_PROVIDER", "signoz")
|
||||
t.Setenv("SIGNOZ_ALERTMANAGER_LEGACY_API__URL", "http://localhost:9093/api")
|
||||
t.Setenv("SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_REPEAT__INTERVAL", "5m")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user