mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-16 21:40:34 +01:00
Compare commits
5 Commits
nv/clone-d
...
feat/llm-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e49d17861c | ||
|
|
990a4e63af | ||
|
|
67f56e0be1 | ||
|
|
c9a6b26be0 | ||
|
|
1dd887f7fd |
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -19,8 +19,5 @@
|
||||
"editor.defaultFormatter": "vscode.html-language-features"
|
||||
},
|
||||
"python-envs.defaultEnvManager": "ms-python.python:system",
|
||||
"python-envs.pythonProjects": [],
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
"python-envs.pythonProjects": []
|
||||
}
|
||||
|
||||
@@ -409,6 +409,10 @@ components:
|
||||
properties:
|
||||
duration:
|
||||
type: string
|
||||
endTime:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
repeatOn:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesRepeatOn'
|
||||
@@ -416,7 +420,11 @@ components:
|
||||
type: array
|
||||
repeatType:
|
||||
$ref: '#/components/schemas/AlertmanagertypesRepeatType'
|
||||
startTime:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- startTime
|
||||
- duration
|
||||
- repeatType
|
||||
type: object
|
||||
@@ -450,7 +458,6 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- timezone
|
||||
- startTime
|
||||
type: object
|
||||
AuthtypesAttributeMapping:
|
||||
properties:
|
||||
@@ -1357,14 +1364,6 @@ components:
|
||||
- appservice
|
||||
- containerapp
|
||||
- aks
|
||||
- sqldatabase
|
||||
- sqldatabasemi
|
||||
- mysqlflexibleserver
|
||||
- postgresqlflexibleserver
|
||||
- mongodb
|
||||
- cosmosdb
|
||||
- cassandradb
|
||||
- redis
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -2437,6 +2436,13 @@ components:
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
DashboardPanelDisplay:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
DashboardTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
@@ -2564,12 +2570,13 @@ components:
|
||||
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
|
||||
type: object
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
$ref: '#/components/schemas/CommonDisplay'
|
||||
duration:
|
||||
type: string
|
||||
layouts:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesLayout'
|
||||
nullable: true
|
||||
type: array
|
||||
links:
|
||||
items:
|
||||
@@ -2578,6 +2585,7 @@ components:
|
||||
panels:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/DashboardtypesPanel'
|
||||
nullable: true
|
||||
type: object
|
||||
refreshInterval:
|
||||
type: string
|
||||
@@ -2585,11 +2593,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesVariable'
|
||||
type: array
|
||||
required:
|
||||
- display
|
||||
- variables
|
||||
- panels
|
||||
- layouts
|
||||
type: object
|
||||
DashboardtypesDatasourcePlugin:
|
||||
discriminator:
|
||||
@@ -2625,15 +2628,6 @@ components:
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesDatasourcePlugin'
|
||||
type: object
|
||||
DashboardtypesDisplay:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesDynamicVariableSpec:
|
||||
properties:
|
||||
name:
|
||||
@@ -2828,7 +2822,7 @@ components:
|
||||
defaultValue:
|
||||
$ref: '#/components/schemas/VariableDefaultValue'
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
$ref: '#/components/schemas/VariableDisplay'
|
||||
name:
|
||||
type: string
|
||||
plugin:
|
||||
@@ -2836,8 +2830,6 @@ components:
|
||||
sort:
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- display
|
||||
type: object
|
||||
DashboardtypesListableDashboardForUserV2:
|
||||
properties:
|
||||
@@ -2965,7 +2957,7 @@ components:
|
||||
DashboardtypesListedDashboardV2Spec:
|
||||
properties:
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
$ref: '#/components/schemas/CommonDisplay'
|
||||
type: object
|
||||
DashboardtypesNumberPanelSpec:
|
||||
properties:
|
||||
@@ -2985,9 +2977,6 @@ components:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelFormatting:
|
||||
properties:
|
||||
@@ -3117,7 +3106,7 @@ components:
|
||||
DashboardtypesPanelSpec:
|
||||
properties:
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
$ref: '#/components/schemas/DashboardPanelDisplay'
|
||||
links:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardLink'
|
||||
@@ -3127,12 +3116,7 @@ components:
|
||||
queries:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesQuery'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- display
|
||||
- plugin
|
||||
- queries
|
||||
type: object
|
||||
DashboardtypesPatchOp:
|
||||
enum:
|
||||
@@ -3201,9 +3185,6 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesQuerySpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesQueryPlugin:
|
||||
discriminator:
|
||||
@@ -3310,8 +3291,6 @@ components:
|
||||
type: string
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesQueryPlugin'
|
||||
required:
|
||||
- plugin
|
||||
type: object
|
||||
DashboardtypesQueryVariableSpec:
|
||||
properties:
|
||||
@@ -3574,6 +3553,10 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/ErrorsResponseerroradditional'
|
||||
type: array
|
||||
invalidReferences:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
message:
|
||||
type: string
|
||||
retry:
|
||||
@@ -3594,10 +3577,6 @@ components:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
suggestions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
ErrorsResponseretryjson:
|
||||
properties:
|
||||
@@ -9012,6 +8991,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -9164,6 +9147,10 @@ paths:
|
||||
$ref: '#/components/schemas/DashboardtypesUpdatablePublicDashboard'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -9758,6 +9745,10 @@ paths:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
@@ -10942,6 +10933,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11055,6 +11050,10 @@ paths:
|
||||
$ref: '#/components/schemas/AuthtypesPatchableRole'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11201,6 +11200,10 @@ paths:
|
||||
$ref: '#/components/schemas/CoretypesPatchableObjects'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -11650,6 +11653,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11757,6 +11764,10 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -11938,6 +11949,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11995,6 +12010,10 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableFactorAPIKey'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -12177,6 +12196,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -12252,6 +12275,10 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"404":
|
||||
content:
|
||||
@@ -12743,53 +12770,6 @@ 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,6 +13456,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -13721,74 +13705,6 @@ paths:
|
||||
summary: Update dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/{id}/clone:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint clones an existing v2-shape dashboard. User and integration
|
||||
dashboards can be cloned; system dashboards are rejected. The clone keeps
|
||||
the source's display name, panels, and tags, but gets a freshly generated
|
||||
unique internal name and is always created as an unlocked user dashboard owned
|
||||
by the caller.
|
||||
operationId: CloneDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Clone dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/{id}/lock:
|
||||
delete:
|
||||
deprecated: false
|
||||
@@ -13803,6 +13719,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -13855,6 +13775,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -15585,6 +15509,10 @@ paths:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesUpdateMetricMetadataRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
@@ -20883,6 +20811,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -20930,6 +20862,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
|
||||
@@ -109,20 +109,6 @@ func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
```
|
||||
|
||||
When you need an ID from `claims` as a `valuer.UUID` (for example to pass it to a module), derive it with the `Must*` constructor instead of `NewUUID` plus an error check. Claims are validated by the auth middleware, so the conversion cannot fail and the error branch would be dead code:
|
||||
|
||||
```go
|
||||
// Good — claims are pre-validated, the conversion cannot fail.
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
// Avoid — the error path is unreachable.
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Register the handler in `signozapiserver`
|
||||
|
||||
In `pkg/apiserver/signozapiserver`, add a route in the appropriate `add*Routes` function (`addUserRoutes`, `addSessionRoutes`, `addOrgRoutes`, etc.). The pattern is:
|
||||
@@ -401,4 +387,3 @@ Note the discriminator property lives in the variants, not on the parent — the
|
||||
- **Add `nullable:"true"`** on fields that can be `null`. Pay special attention to slices and maps -- in Go these default to `nil` which serializes to `null`. If the field should always be an array, initialize it and do not mark it nullable.
|
||||
- **Implement `Enum()`** on every type that has a fixed set of acceptable values so the JSON schema generates proper `enum` constraints.
|
||||
- **Add request examples** via `RequestExamples` in `OpenAPIDef` for any non-trivial endpoint. See `pkg/apiserver/signozapiserver/querier.go` for reference.
|
||||
- **Derive IDs from `claims` with `valuer.MustNewUUID`** (e.g. `claims.OrgID`, `claims.UserID`). Claims are pre-validated by the auth middleware, so use the `Must*` constructor — don't write `NewUUID` followed by an `if err != nil { render.Error(...); return }` block.
|
||||
|
||||
@@ -217,10 +217,6 @@ func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy
|
||||
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, source, postable)
|
||||
}
|
||||
|
||||
func (module *module) CloneV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.CloneV2(ctx, orgID, createdBy, creator, id)
|
||||
}
|
||||
|
||||
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
@@ -258,12 +254,12 @@ func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID value
|
||||
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
|
||||
}
|
||||
|
||||
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
|
||||
return module.pkgDashboardModule.UnpinV2(ctx, orgID, userID, id)
|
||||
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
|
||||
return module.pkgDashboardModule.UnpinV2(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
|
||||
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
|
||||
func (module *module) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
|
||||
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
|
||||
@@ -3,13 +3,13 @@ package querier
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
@@ -48,8 +48,8 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
var queryRangeRequest qbtypes.QueryRangeRequest
|
||||
if err := binding.JSON.BindBody(req.Body, &queryRangeRequest); err != nil {
|
||||
render.Error(rw, err)
|
||||
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
|
||||
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to decode request body: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
|
||||
|
||||
@@ -291,8 +291,6 @@
|
||||
// Prevents the usage of specific antd components in favor of our lib
|
||||
"signoz/no-signozhq-ui-barrel": "error",
|
||||
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
|
||||
"signoz/no-css-module-bracket-access": "warn",
|
||||
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -2,33 +2,9 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
path.join(__dirname, 'stylelint-rules/no-unsupported-asset-url.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/no-deep-nesting.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/no-id-selectors.js'),
|
||||
path.join(
|
||||
__dirname,
|
||||
'stylelint-rules/css-modules/no-bare-element-selectors.js',
|
||||
),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/prefer-css-variables.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/class-name-pattern.js'),
|
||||
],
|
||||
plugins: [path.join(__dirname, 'stylelint-rules/no-unsupported-asset-url.js')],
|
||||
customSyntax: 'postcss-scss',
|
||||
rules: {
|
||||
// Applies to all SCSS files
|
||||
'local/no-unsupported-asset-url': true,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// CSS module-specific rules
|
||||
files: ['**/*.module.scss'],
|
||||
rules: {
|
||||
'local/no-deep-nesting': [true, { severity: 'warning' }],
|
||||
'local/no-id-selectors': true,
|
||||
'local/no-bare-element-selectors': true,
|
||||
'local/prefer-css-variables': [true, { severity: 'warning' }],
|
||||
'local/class-name-pattern': [true, { severity: 'warning' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -23,8 +23,6 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Always add data-testid or testId (if supported) to critical/behavioral components like inputs, buttons, etc...
|
||||
- When creating test, these IDs should be used instead of finding by role.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
|
||||
@@ -1,513 +0,0 @@
|
||||
# CSS Modules Guide
|
||||
|
||||
## Checklist Before Committing
|
||||
|
||||
- [ ] All class names use camelCase in CSS
|
||||
- [ ] State classes use `is-`/`has-` prefix (e.g., `isActive`, `hasError`)
|
||||
- [ ] No bracket access (`styles['...']`) in JS unless verified
|
||||
- [ ] No dynamic class lookup - use explicit variant maps instead
|
||||
- [ ] No deep class nesting (max 3 class levels; pseudo-classes/elements and parent-reference selectors like `&.active`, `&#bar` are not counted)
|
||||
- [ ] No hardcoded colors - use `--l1/l2/l3-*` semantic tokens (not `--bg-*` primitives)
|
||||
- [ ] No magic numbers - use `--spacing-*` tokens
|
||||
- [ ] Typography uses `--periscope-font-size-*` or `--font-size-*` tokens
|
||||
- [ ] @signozhq/ui overrides use CSS variables, not direct class overrides
|
||||
- [ ] Global escapes only for third-party overrides
|
||||
- [ ] No ID selectors
|
||||
- [ ] No bare element selectors
|
||||
- [ ] Keyframes use `:local(@keyframes name)` to avoid global collisions
|
||||
|
||||
## Config (vite.config.ts)
|
||||
|
||||
```ts
|
||||
css: {
|
||||
modules: {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** `camelCaseOnly` exports ONLY camelCase keys. Original kebab-case NOT accessible.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| CSS Class | JS Access | Works? | Preferred? |
|
||||
|-----------|-----------|--------|----------------------------|
|
||||
| `.alertHistory` | `styles.alertHistory` | Yes | Yes |
|
||||
| `.alert-history` | `styles.alertHistory` | Yes | No, use `.alertHistory` |
|
||||
| `.alert-history` | `styles['alert-history']` | NO - undefined | Never, use `.alertHistory` |
|
||||
|
||||
## Bad Patterns
|
||||
|
||||
### Class Naming
|
||||
|
||||
```scss
|
||||
// BAD: Bracket access won't work
|
||||
.my-class { }
|
||||
// Then in JS: styles['my-class'] -> undefined
|
||||
|
||||
// BAD: Collision - both become same key
|
||||
.alertHistory { }
|
||||
.alert-history { } // -> styles.alertHistory (conflicts)
|
||||
|
||||
// BAD: Underscore inconsistency
|
||||
.my_class { } // -> styles.myClass (confusing)
|
||||
|
||||
// GOOD: Direct camelCase
|
||||
.alertHistory { }
|
||||
.statsCard { }
|
||||
|
||||
// GOOD: State classes with is-/has- prefix
|
||||
.isDisabled { }
|
||||
.isActive { }
|
||||
.hasError { }
|
||||
.isLoading { }
|
||||
```
|
||||
|
||||
### Nesting
|
||||
|
||||
```scss
|
||||
// BAD: Deep nesting - specificity wars, hard to override
|
||||
.container {
|
||||
.wrapper {
|
||||
.inner {
|
||||
.content { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BAD: Nesting creates separate classes you might not expect
|
||||
.button {
|
||||
.icon { } // -> styles.icon (separate class, not scoped under .button)
|
||||
}
|
||||
|
||||
// GOOD: Flat structure
|
||||
.container { }
|
||||
.containerWrapper { }
|
||||
.containerContent { }
|
||||
|
||||
// GOOD: Nesting only for pseudo/states
|
||||
.button {
|
||||
&:hover { }
|
||||
&:disabled { }
|
||||
&::before { }
|
||||
}
|
||||
```
|
||||
|
||||
### Global Escapes
|
||||
|
||||
```scss
|
||||
// BAD: Overusing global
|
||||
:global {
|
||||
.everything { }
|
||||
.in-here { }
|
||||
.is-global { }
|
||||
}
|
||||
|
||||
// BAD: Global without necessity
|
||||
:global(.myComponent) { } // defeats purpose of modules
|
||||
|
||||
// GOOD: Targeted global for third-party overrides
|
||||
.container {
|
||||
:global(.ant-modal-content) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Selectors
|
||||
|
||||
```scss
|
||||
// BAD: ID selectors - not reusable
|
||||
#myComponent { }
|
||||
|
||||
// BAD: Element selectors without scope
|
||||
div { } // affects ALL divs in component
|
||||
|
||||
// BAD: Complex selectors
|
||||
.container > div + span ~ p { }
|
||||
|
||||
// GOOD: Class-only selectors
|
||||
.container { }
|
||||
.title { }
|
||||
```
|
||||
|
||||
### Variables & Values
|
||||
|
||||
```scss
|
||||
// BAD: Hardcoded colors
|
||||
.button {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
// BAD: Magic numbers
|
||||
.container {
|
||||
padding: 17px;
|
||||
margin-left: 43px;
|
||||
}
|
||||
|
||||
// GOOD: Semantic tokens (theme-aware)
|
||||
.button {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
// GOOD: Spacing system
|
||||
.container {
|
||||
padding: var(--spacing-4);
|
||||
margin-left: var(--spacing-5);
|
||||
}
|
||||
```
|
||||
|
||||
## Design Tokens (@signozhq/design-tokens)
|
||||
|
||||
Prefer semantic tokens over hardcoded values.
|
||||
|
||||
You can read the ./node_modules/@signozhq/design-tokens/dist/style.css to find complete list of available tokens.
|
||||
|
||||
### Spacing
|
||||
|
||||
```scss
|
||||
// Spacing scale (index -> px):
|
||||
// --spacing-0=0 --spacing-1=2 --spacing-2=4 --spacing-3=6 --spacing-4=8
|
||||
// --spacing-5=10 --spacing-6=12 --spacing-7=14 --spacing-8=16 --spacing-10=20
|
||||
// --spacing-12=24 --spacing-16=32 --spacing-20=40 --spacing-24=48 --spacing-32=64
|
||||
// --spacing-40=80 --spacing-48=96 --spacing-56=112 --spacing-64=128
|
||||
// (index != px; --spacing-2 is 4px, not 2px)
|
||||
.container {
|
||||
padding: var(--spacing-4); // 8px
|
||||
gap: var(--spacing-6); // 12px
|
||||
margin-bottom: var(--spacing-8); // 16px
|
||||
}
|
||||
|
||||
// Also available: --padding-* and --margin-* (rem-based)
|
||||
// --padding-1 = 0.25rem, --padding-4 = 1rem, etc.
|
||||
```
|
||||
|
||||
### Typography
|
||||
|
||||
```scss
|
||||
// Font sizes (preferred)
|
||||
.title {
|
||||
font-size: var(--periscope-font-size-large); // 18px
|
||||
font-size: var(--periscope-font-size-medium); // 16px
|
||||
font-size: var(--periscope-font-size-base); // 13px
|
||||
font-size: var(--periscope-font-size-small); // 11px
|
||||
}
|
||||
|
||||
// Alternative scale (rem-based)
|
||||
.heading {
|
||||
font-size: var(--font-size-xl); // 1.25rem
|
||||
font-size: var(--font-size-lg); // 1.125rem
|
||||
font-size: var(--font-size-base); // 1rem
|
||||
font-size: var(--font-size-sm); // 0.875rem
|
||||
}
|
||||
|
||||
// Font weights
|
||||
.bold {
|
||||
font-weight: var(--font-weight-semibold); // 600
|
||||
font-weight: var(--font-weight-medium); // 500
|
||||
font-weight: var(--font-weight-normal); // 400
|
||||
}
|
||||
|
||||
// Line heights
|
||||
.text {
|
||||
line-height: var(--line-height-20); // 20px
|
||||
line-height: var(--line-height-24); // 24px
|
||||
}
|
||||
```
|
||||
|
||||
### Colors (Prefer Semantic Tokens)
|
||||
|
||||
Use L1/L2/L3 semantic tokens - they handle light/dark theme automatically.
|
||||
|
||||
```scss
|
||||
// BAD: Primitive tokens (fixed value across themes, won't swap on theme change)
|
||||
.card {
|
||||
background: var(--bg-ink-400);
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
// GOOD: L1/L2/L3 tokens (theme-aware - swap automatically light/dark)
|
||||
.card {
|
||||
background: var(--l1-background); // base layer
|
||||
color: var(--l1-foreground); // primary text
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--l2-background); // elevated surface
|
||||
color: var(--l2-foreground); // secondary text
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.nested {
|
||||
background: var(--l3-background); // nested/inset
|
||||
color: var(--l3-foreground); // tertiary text
|
||||
}
|
||||
|
||||
// Hover states
|
||||
.card:hover {
|
||||
background: var(--l1-background-hover);
|
||||
color: var(--l1-foreground-hover);
|
||||
}
|
||||
|
||||
// Semantic action colors (also theme-aware)
|
||||
.primary {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: var(--danger-background);
|
||||
color: var(--danger-foreground);
|
||||
}
|
||||
|
||||
.success {
|
||||
background: var(--success-background);
|
||||
color: var(--success-foreground);
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--warning-background);
|
||||
color: var(--warning-foreground);
|
||||
}
|
||||
|
||||
// Accent colors (for highlights, badges, etc.)
|
||||
.accent {
|
||||
background: var(--accent-primary); // robin blue
|
||||
background: var(--accent-forest); // green
|
||||
background: var(--accent-cherry); // red
|
||||
background: var(--accent-amber); // yellow
|
||||
}
|
||||
```
|
||||
|
||||
**Token hierarchy:**
|
||||
- Primitive tokens (`--bg-*`, `--text-*`, etc.) have fixed values across themes.
|
||||
- Semantic tokens (L1/L2/L3, `--primary-*`, `--danger-*`, etc.) automatically swap based on theme.
|
||||
- L1 = base/root layer
|
||||
- L2 = elevated surfaces (cards, panels)
|
||||
- L3 = nested/inset elements
|
||||
|
||||
## Overriding @signozhq/ui Components
|
||||
|
||||
Components expose CSS variables for customization.
|
||||
|
||||
You can ensure they exist by looking at ./node_modules/@signozhq/ui/dist.
|
||||
Never write a override without confirm it exists.
|
||||
|
||||
Override via:
|
||||
|
||||
### Method 1: CSS Variables (Preferred)
|
||||
|
||||
Each component exposes `--<component>-<property>` variables:
|
||||
|
||||
```scss
|
||||
// Override Button
|
||||
.customButton {
|
||||
--button-background: var(--success-background);
|
||||
--button-border-radius: var(--radius-2);
|
||||
--button-padding: var(--spacing-4) var(--spacing-8);
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
// Override Input
|
||||
.customInput {
|
||||
--input-height: 2.5rem;
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-padding: var(--spacing-2) var(--spacing-6);
|
||||
--input-placeholder-color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// Override nested parts
|
||||
.customInput {
|
||||
--input-prefix-padding: 0 var(--spacing-4) 0 var(--spacing-6);
|
||||
--input-suffix-color: var(--accent-primary);
|
||||
}
|
||||
```
|
||||
|
||||
### Method 2: Data Attributes
|
||||
|
||||
Components use data attributes for variants/states. Target them for state-specific overrides:
|
||||
|
||||
```scss
|
||||
// Target variant
|
||||
.wrapper :global([data-variant="outlined"]) {
|
||||
--button-border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
// Target size
|
||||
.wrapper :global([data-size="sm"]) {
|
||||
--button-font-size: var(--periscope-font-size-small);
|
||||
}
|
||||
|
||||
// Target color
|
||||
.wrapper :global([data-color="destructive"]) {
|
||||
--button-background: var(--danger-background);
|
||||
}
|
||||
|
||||
// Target state (Radix patterns)
|
||||
.popover :global([data-state="open"]) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip :global([data-side="top"]) {
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
```
|
||||
|
||||
### Common Component CSS Variables
|
||||
|
||||
**Button:**
|
||||
- `--button-background`, `--button-border-radius`, `--button-padding`
|
||||
- `--button-font-size`, `--button-height`, `--button-gap`
|
||||
- `--button-hover-background`, `--button-disabled-opacity`
|
||||
|
||||
**Input:**
|
||||
- `--input-height`, `--input-border-color`, `--input-background`
|
||||
- `--input-padding`, `--input-font-size`, `--input-placeholder-color`
|
||||
- `--input-focus-outline-color`, `--input-hover-border-color`
|
||||
- `--input-prefix-*`, `--input-suffix-*` for adornments
|
||||
|
||||
**General pattern:** `--<component>-<property>` or `--<component>-<state>-<property>`
|
||||
|
||||
## Good Patterns
|
||||
|
||||
### Structure
|
||||
|
||||
```scss
|
||||
// Flat, descriptive, component-scoped
|
||||
.alertHistory { }
|
||||
.alertHistoryHeader { }
|
||||
.alertHistoryContent { }
|
||||
.alertHistoryFooter { }
|
||||
|
||||
// State modifiers as separate classes
|
||||
.alertHistory { }
|
||||
.alertHistoryLoading { }
|
||||
.alertHistoryEmpty { }
|
||||
.alertHistoryError { }
|
||||
```
|
||||
|
||||
### Composition
|
||||
|
||||
```scss
|
||||
// GOOD: Composing styles
|
||||
.baseButton {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
composes: baseButton;
|
||||
background: var(--primary-background);
|
||||
}
|
||||
```
|
||||
|
||||
### Pseudo Elements
|
||||
|
||||
```scss
|
||||
.button {
|
||||
// States
|
||||
&:hover { opacity: 0.9; }
|
||||
&:focus { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
// Pseudo elements
|
||||
&::before { content: ''; }
|
||||
&::after { content: ''; }
|
||||
}
|
||||
```
|
||||
|
||||
### Media Queries
|
||||
|
||||
```scss
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Keyframes (Local Scoping)
|
||||
|
||||
Without `:local()`, keyframe names are global and can clash across modules:
|
||||
|
||||
```scss
|
||||
// BAD: Global keyframe - can conflict with other modules
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
// GOOD: Locally scoped keyframe
|
||||
:local(@keyframes fadeIn) {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal {
|
||||
animation: fadeIn 200ms ease;
|
||||
}
|
||||
```
|
||||
|
||||
## JS Import Patterns
|
||||
|
||||
```tsx
|
||||
// GOOD
|
||||
import styles from './Component.module.scss';
|
||||
|
||||
<div className={styles.container}>
|
||||
<span className={styles.title}>Title</span>
|
||||
</div>
|
||||
|
||||
// GOOD: Conditional classes
|
||||
<div className={`${styles.button} ${isActive ? styles.buttonActive : ''}`}>
|
||||
|
||||
// GOOD: With clsx/classnames
|
||||
<div className={clsx(styles.button, { [styles.buttonActive]: isActive })}>
|
||||
|
||||
// BAD: Bracket access (may be undefined)
|
||||
<div className={styles['button-active']}> // undefined if CSS has .button-active
|
||||
|
||||
// BAD: String interpolation for class names
|
||||
<div className={`${styles.button}-active`}> // won't work
|
||||
|
||||
// BAD: Dynamic class lookup - can't be statically analyzed
|
||||
const cls = styles[`variant${props.type}`]; // Vite can't tree-shake or type-check
|
||||
|
||||
// GOOD: Explicit map for dynamic variants
|
||||
const variantMap = {
|
||||
primary: styles.variantPrimary,
|
||||
secondary: styles.variantSecondary,
|
||||
ghost: styles.variantGhost,
|
||||
};
|
||||
const cls = variantMap[props.type];
|
||||
```
|
||||
|
||||
## Lint Rules
|
||||
|
||||
### JS/TS (oxlint)
|
||||
|
||||
| Rule | Severity | Catches |
|
||||
|------|----------|---------|
|
||||
| `signoz/no-css-module-bracket-access` | warn | `styles['kebab-case']`, dynamic access |
|
||||
|
||||
### CSS/SCSS (stylelint)
|
||||
|
||||
| Rule | Severity | Catches |
|
||||
|------|----------|---------|
|
||||
| `local/no-deep-nesting` | warning | class nesting >3 levels (pseudo-classes/elements and parent-reference selectors `&.foo`, `&#bar` not counted; configurable via `maxDepth` secondary option) |
|
||||
| `local/no-id-selectors` | error | `#id` selectors |
|
||||
| `local/no-bare-element-selectors` | error | root-level `div`, `span` etc |
|
||||
| `local/prefer-css-variables` | warning | hardcoded colors |
|
||||
| `local/class-name-pattern` | warning | kebab-case, snake_case, PascalCase |
|
||||
|
||||
Run: `pnpm lint:styles` to check CSS modules.
|
||||
@@ -192,9 +192,9 @@
|
||||
"lint-staged": "^17.0.4",
|
||||
"msw": "1.3.2",
|
||||
"orval": "8.9.1",
|
||||
"oxfmt": "0.54.0",
|
||||
"oxlint": "1.69.0",
|
||||
"oxlint-tsgolint": "0.23.0",
|
||||
"oxfmt": "0.47.0",
|
||||
"oxlint": "1.62.0",
|
||||
"oxlint-tsgolint": "0.22.1",
|
||||
"postcss": "8.5.14",
|
||||
"postcss-scss": "4.0.9",
|
||||
"react-resizable": "3.0.4",
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Rule: no-css-module-bracket-access
|
||||
*
|
||||
* Prevents bracket access on CSS module imports that may fail with camelCaseOnly config.
|
||||
*
|
||||
* With Vite's `localsConvention: 'camelCaseOnly'`, kebab-case class names are
|
||||
* converted to camelCase and the original key is NOT exported.
|
||||
*
|
||||
* This rule catches patterns like:
|
||||
* styles['my-class'] // BAD - undefined if CSS has .my-class
|
||||
* styles['myClass'] // OK but prefer dot notation
|
||||
* styles.myClass // GOOD
|
||||
*
|
||||
* Catches:
|
||||
* - Bracket access with kebab-case strings (always fails)
|
||||
* - Bracket access with any string literal (warn - prefer dot notation)
|
||||
* - Dynamic bracket access (warn - risky)
|
||||
*/
|
||||
|
||||
const CSS_MODULE_IMPORT_NAMES = new Set([
|
||||
'styles',
|
||||
'classes',
|
||||
'css',
|
||||
'classNames',
|
||||
]);
|
||||
|
||||
function looksLikeCssModuleImport(name) {
|
||||
// Common patterns: styles, componentStyles, alertHistoryStyles
|
||||
return (
|
||||
CSS_MODULE_IMPORT_NAMES.has(name) ||
|
||||
name.endsWith('Styles') ||
|
||||
name.endsWith('Classes') ||
|
||||
name.endsWith('Css')
|
||||
);
|
||||
}
|
||||
|
||||
function isKebabCase(str) {
|
||||
return str.includes('-');
|
||||
}
|
||||
|
||||
function isSnakeCase(str) {
|
||||
return str.includes('_');
|
||||
}
|
||||
|
||||
export default {
|
||||
create(context) {
|
||||
return {
|
||||
MemberExpression(node) {
|
||||
// Only check bracket notation: styles['...']
|
||||
if (!node.computed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const object = node.object;
|
||||
if (object.type !== 'Identifier') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this looks like a CSS module import
|
||||
if (!looksLikeCssModuleImport(object.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const property = node.property;
|
||||
|
||||
// Dynamic access: styles[variable]
|
||||
if (property.type === 'Identifier') {
|
||||
context.report({
|
||||
node,
|
||||
message: `Dynamic CSS module access '${object.name}[${property.name}]' is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Use dot notation or verify the key exists.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Template literal: styles[\`...\`]
|
||||
if (property.type === 'TemplateLiteral') {
|
||||
context.report({
|
||||
node,
|
||||
message: `Template literal CSS module access is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Prefer dot notation.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Numeric / boolean / null literal: styles[0]. Not a class lookup; ignore.
|
||||
if (property.type === 'Literal' && typeof property.value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
// String literal: styles['...']
|
||||
if (property.type === 'Literal' && typeof property.value === 'string') {
|
||||
const className = property.value;
|
||||
|
||||
// Kebab-case will definitely fail
|
||||
if (isKebabCase(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `CSS module class '${className}' uses kebab-case which won't work with 'camelCaseOnly' config. Use '${object.name}.${toCamelCase(className)}' instead.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Snake_case is suspicious
|
||||
if (isSnakeCase(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `CSS module class '${className}' uses snake_case which may not work as expected. Prefer camelCase: '${object.name}.${toCamelCase(className)}'.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid camelCase but using bracket notation - prefer dot
|
||||
if (/^[a-z][a-zA-Z0-9]*$/.test(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Prefer dot notation: '${object.name}.${className}' instead of '${object.name}['${className}']'.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Catch-all for other dynamic expressions:
|
||||
// styles['prefix' + suffix] (BinaryExpression)
|
||||
// styles[isActive && 'foo'] (LogicalExpression)
|
||||
// styles[isActive ? 'a' : 'b'] (ConditionalExpression)
|
||||
// styles[fn()] (CallExpression)
|
||||
context.report({
|
||||
node,
|
||||
message: `Dynamic CSS module access on '${object.name}' is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Use dot notation or verify each key resolves to an exported camelCase class.`,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function toCamelCase(str) {
|
||||
return str
|
||||
.split(/[-_]/)
|
||||
.map((part, i) =>
|
||||
i === 0
|
||||
? part.toLowerCase()
|
||||
: part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import noUnsupportedAssetPattern from './rules/no-unsupported-asset-pattern.mjs'
|
||||
import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
|
||||
import noAntdComponents from './rules/no-antd-components.mjs';
|
||||
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
|
||||
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -24,6 +23,5 @@ export default {
|
||||
'no-raw-absolute-path': noRawAbsolutePath,
|
||||
'no-antd-components': noAntdComponents,
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
'no-css-module-bracket-access': noCssModuleBracketAccess,
|
||||
},
|
||||
};
|
||||
|
||||
413
frontend/pnpm-lock.yaml
generated
413
frontend/pnpm-lock.yaml
generated
@@ -462,14 +462,14 @@ importers:
|
||||
specifier: 8.9.1
|
||||
version: 8.9.1(prettier@3.8.3)(typescript@5.9.3)
|
||||
oxfmt:
|
||||
specifier: 0.54.0
|
||||
version: 0.54.0
|
||||
specifier: 0.47.0
|
||||
version: 0.47.0
|
||||
oxlint:
|
||||
specifier: 1.69.0
|
||||
version: 1.69.0(oxlint-tsgolint@0.23.0)
|
||||
specifier: 1.62.0
|
||||
version: 1.62.0(oxlint-tsgolint@0.22.1)
|
||||
oxlint-tsgolint:
|
||||
specifier: 0.23.0
|
||||
version: 0.23.0
|
||||
specifier: 0.22.1
|
||||
version: 0.22.1
|
||||
postcss:
|
||||
specifier: 8.5.14
|
||||
version: 8.5.14
|
||||
@@ -505,7 +505,7 @@ importers:
|
||||
version: 1.6.0(react@18.2.0)
|
||||
vite-plugin-checker:
|
||||
specifier: 0.12.0
|
||||
version: 0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(stylelint@17.7.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
version: 0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.62.0(oxlint-tsgolint@0.22.1))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(stylelint@17.7.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
vite-plugin-compression:
|
||||
specifier: 0.5.1
|
||||
version: 0.5.1(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))
|
||||
@@ -2123,276 +2123,276 @@ packages:
|
||||
'@oxc-project/types@0.101.0':
|
||||
resolution: {integrity: sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ==}
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.54.0':
|
||||
resolution: {integrity: sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==}
|
||||
'@oxfmt/binding-android-arm-eabi@0.47.0':
|
||||
resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.54.0':
|
||||
resolution: {integrity: sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ==}
|
||||
'@oxfmt/binding-android-arm64@0.47.0':
|
||||
resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.54.0':
|
||||
resolution: {integrity: sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g==}
|
||||
'@oxfmt/binding-darwin-arm64@0.47.0':
|
||||
resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.54.0':
|
||||
resolution: {integrity: sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ==}
|
||||
'@oxfmt/binding-darwin-x64@0.47.0':
|
||||
resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.54.0':
|
||||
resolution: {integrity: sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ==}
|
||||
'@oxfmt/binding-freebsd-x64@0.47.0':
|
||||
resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
|
||||
resolution: {integrity: sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg==}
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
|
||||
resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
|
||||
resolution: {integrity: sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw==}
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
|
||||
resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w==}
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==}
|
||||
'@oxfmt/binding-linux-arm64-musl@0.47.0':
|
||||
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==}
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==}
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==}
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
|
||||
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==}
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==}
|
||||
'@oxfmt/binding-linux-x64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==}
|
||||
'@oxfmt/binding-linux-x64-musl@0.47.0':
|
||||
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.54.0':
|
||||
resolution: {integrity: sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==}
|
||||
'@oxfmt/binding-openharmony-arm64@0.47.0':
|
||||
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
|
||||
resolution: {integrity: sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA==}
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
|
||||
resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
|
||||
resolution: {integrity: sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg==}
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
|
||||
resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.54.0':
|
||||
resolution: {integrity: sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg==}
|
||||
'@oxfmt/binding-win32-x64-msvc@0.47.0':
|
||||
resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.23.0':
|
||||
resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==}
|
||||
'@oxlint-tsgolint/darwin-arm64@0.22.1':
|
||||
resolution: {integrity: sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.23.0':
|
||||
resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==}
|
||||
'@oxlint-tsgolint/darwin-x64@0.22.1':
|
||||
resolution: {integrity: sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.23.0':
|
||||
resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==}
|
||||
'@oxlint-tsgolint/linux-arm64@0.22.1':
|
||||
resolution: {integrity: sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.23.0':
|
||||
resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==}
|
||||
'@oxlint-tsgolint/linux-x64@0.22.1':
|
||||
resolution: {integrity: sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.23.0':
|
||||
resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==}
|
||||
'@oxlint-tsgolint/win32-arm64@0.22.1':
|
||||
resolution: {integrity: sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.23.0':
|
||||
resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==}
|
||||
'@oxlint-tsgolint/win32-x64@0.22.1':
|
||||
resolution: {integrity: sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.69.0':
|
||||
resolution: {integrity: sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==}
|
||||
'@oxlint/binding-android-arm-eabi@1.62.0':
|
||||
resolution: {integrity: sha512-pKsthNECyvJh8lPTICz6VcwVy2jOqdhhsp1rlxCkhgZR47aKvXPmaRWQDv+zlXpRae4qm1MaaTnutkaOk5aofg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-android-arm64@1.69.0':
|
||||
resolution: {integrity: sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==}
|
||||
'@oxlint/binding-android-arm64@1.62.0':
|
||||
resolution: {integrity: sha512-b1AUNViByvgmR2xJDubvLIr+dSuu3uraG7bsAoKo+xrpspPvu6RIn6Fhr2JUhobfep3jwUTy18Huco6GkwdvGQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.69.0':
|
||||
resolution: {integrity: sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==}
|
||||
'@oxlint/binding-darwin-arm64@1.62.0':
|
||||
resolution: {integrity: sha512-iG+Tvf70UJ6otfwFYIHk36Sjq9cpPP5YLxkoggANNRtzgi3Tj3g8q6Ybqi6AtkU3+yg9QwF7bDCkCS6bbL4PCg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.69.0':
|
||||
resolution: {integrity: sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==}
|
||||
'@oxlint/binding-darwin-x64@1.62.0':
|
||||
resolution: {integrity: sha512-oOWI6YPPr5AJUx+yIDlxmuUbQjS5gZX3OH3QisawYvsZgLiQVvZtR0rPBcJTxLWqt2ClrWg0DlSrlUiG5SQNHg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.69.0':
|
||||
resolution: {integrity: sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==}
|
||||
'@oxlint/binding-freebsd-x64@1.62.0':
|
||||
resolution: {integrity: sha512-dLP33T7VLCmLVv4cvjkVX+rmkcwNk2UfxmsZPNur/7BQHoQR60zJ7XLiRvNUawlzn0u8ngCa3itjEG73MAMa/w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
|
||||
resolution: {integrity: sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==}
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.62.0':
|
||||
resolution: {integrity: sha512-fl//LWNks6qo9chNY60UDYyIwtp7a5cEx4Y/rHPjaarhuwqx6jtbzEpD5V5AqmdL4a6Y5D8zeXg5HF2Cr0QmSQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
|
||||
resolution: {integrity: sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==}
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.62.0':
|
||||
resolution: {integrity: sha512-i5vkAuxvueTODV3J2dL61/TXewDHhMFKvtD156cIsk7GsdfiAu7zW7kY0NJXhKeFHeiMZIh7eFNjkPYH6J47HQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==}
|
||||
'@oxlint/binding-linux-arm64-gnu@1.62.0':
|
||||
resolution: {integrity: sha512-QwN19LLuIGuOjEflSeJkZmOTfBdBMlTmW8xbMf8TZhjd//cxVNYQPq75q7oKZBJc6hRx3gY7sX0Egc8cEIFZYg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==}
|
||||
'@oxlint/binding-linux-arm64-musl@1.62.0':
|
||||
resolution: {integrity: sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==}
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
|
||||
resolution: {integrity: sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==}
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
|
||||
resolution: {integrity: sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==}
|
||||
'@oxlint/binding-linux-riscv64-musl@1.62.0':
|
||||
resolution: {integrity: sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==}
|
||||
'@oxlint/binding-linux-s390x-gnu@1.62.0':
|
||||
resolution: {integrity: sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==}
|
||||
'@oxlint/binding-linux-x64-gnu@1.62.0':
|
||||
resolution: {integrity: sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==}
|
||||
'@oxlint/binding-linux-x64-musl@1.62.0':
|
||||
resolution: {integrity: sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.69.0':
|
||||
resolution: {integrity: sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==}
|
||||
'@oxlint/binding-openharmony-arm64@1.62.0':
|
||||
resolution: {integrity: sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.69.0':
|
||||
resolution: {integrity: sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==}
|
||||
'@oxlint/binding-win32-arm64-msvc@1.62.0':
|
||||
resolution: {integrity: sha512-EiFXr8loNS0Ul3Gu80+9nr1T8jRmnKocqmHHg16tj5ZqTgUXyb97l2rrspVHdDluyFn9JfR4PoJFdNzw4paHww==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.69.0':
|
||||
resolution: {integrity: sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==}
|
||||
'@oxlint/binding-win32-ia32-msvc@1.62.0':
|
||||
resolution: {integrity: sha512-IgOFvL73li1bFgab+hThXYA0N2Xms2kV2MvZN95cebV+fmrZ9AVui1JSxfeeqRLo3CpPxKZlzhyq4G0cnaAvIw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.69.0':
|
||||
resolution: {integrity: sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==}
|
||||
'@oxlint/binding-win32-x64-msvc@1.62.0':
|
||||
resolution: {integrity: sha512-6hMpyDWQ2zGA1OXFKBrdYMUveUCO8UJhkO6JdwZPd78xIdHZNhjx+pib+4fC2Cljuhjyl0QwA2F3df/bs4Bp6A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -6885,35 +6885,24 @@ packages:
|
||||
overlayscrollbars@2.9.2:
|
||||
resolution: {integrity: sha512-iDT84r39i7oWP72diZN2mbJUsn/taCq568aQaIrc84S87PunBT7qtsVltAF2esk7ORTRjQDnfjVYoqqTzgs8QA==}
|
||||
|
||||
oxfmt@0.54.0:
|
||||
resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==}
|
||||
oxfmt@0.47.0:
|
||||
resolution: {integrity: sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
oxlint-tsgolint@0.22.1:
|
||||
resolution: {integrity: sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.62.0:
|
||||
resolution: {integrity: sha512-1uFkg6HakjsGIpW9wNdeW4/2LOHW9MEkoWjZUTUfQtIHyLIZPYt00w3Sg+H3lH+206FgBPHBbW5dVE5l2ExECQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
vite-plus: '*'
|
||||
peerDependenciesMeta:
|
||||
svelte:
|
||||
optional: true
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
oxlint-tsgolint@0.23.0:
|
||||
resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.69.0:
|
||||
resolution: {integrity: sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
oxlint-tsgolint: '>=0.22.1'
|
||||
vite-plus: '*'
|
||||
oxlint-tsgolint: '>=0.18.0'
|
||||
peerDependenciesMeta:
|
||||
oxlint-tsgolint:
|
||||
optional: true
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
@@ -11050,136 +11039,136 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.101.0': {}
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.54.0':
|
||||
'@oxfmt/binding-android-arm-eabi@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.54.0':
|
||||
'@oxfmt/binding-android-arm64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.54.0':
|
||||
'@oxfmt/binding-darwin-arm64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.54.0':
|
||||
'@oxfmt/binding-darwin-x64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.54.0':
|
||||
'@oxfmt/binding-freebsd-x64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.54.0':
|
||||
'@oxfmt/binding-linux-arm64-musl@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.54.0':
|
||||
'@oxfmt/binding-linux-x64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.54.0':
|
||||
'@oxfmt/binding-linux-x64-musl@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.54.0':
|
||||
'@oxfmt/binding-openharmony-arm64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.54.0':
|
||||
'@oxfmt/binding-win32-x64-msvc@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.23.0':
|
||||
'@oxlint-tsgolint/darwin-arm64@0.22.1':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.23.0':
|
||||
'@oxlint-tsgolint/darwin-x64@0.22.1':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.23.0':
|
||||
'@oxlint-tsgolint/linux-arm64@0.22.1':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.23.0':
|
||||
'@oxlint-tsgolint/linux-x64@0.22.1':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.23.0':
|
||||
'@oxlint-tsgolint/win32-arm64@0.22.1':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.23.0':
|
||||
'@oxlint-tsgolint/win32-x64@0.22.1':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.69.0':
|
||||
'@oxlint/binding-android-arm-eabi@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm64@1.69.0':
|
||||
'@oxlint/binding-android-arm64@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.69.0':
|
||||
'@oxlint/binding-darwin-arm64@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.69.0':
|
||||
'@oxlint/binding-darwin-x64@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.69.0':
|
||||
'@oxlint/binding-freebsd-x64@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.69.0':
|
||||
'@oxlint/binding-linux-arm64-gnu@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.69.0':
|
||||
'@oxlint/binding-linux-arm64-musl@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.69.0':
|
||||
'@oxlint/binding-linux-riscv64-musl@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.69.0':
|
||||
'@oxlint/binding-linux-s390x-gnu@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.69.0':
|
||||
'@oxlint/binding-linux-x64-gnu@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.69.0':
|
||||
'@oxlint/binding-linux-x64-musl@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.69.0':
|
||||
'@oxlint/binding-openharmony-arm64@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.69.0':
|
||||
'@oxlint/binding-win32-arm64-msvc@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.69.0':
|
||||
'@oxlint/binding-win32-ia32-msvc@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.69.0':
|
||||
'@oxlint/binding-win32-x64-msvc@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
@@ -16388,61 +16377,61 @@ snapshots:
|
||||
|
||||
overlayscrollbars@2.9.2: {}
|
||||
|
||||
oxfmt@0.54.0:
|
||||
oxfmt@0.47.0:
|
||||
dependencies:
|
||||
tinypool: 2.1.0
|
||||
optionalDependencies:
|
||||
'@oxfmt/binding-android-arm-eabi': 0.54.0
|
||||
'@oxfmt/binding-android-arm64': 0.54.0
|
||||
'@oxfmt/binding-darwin-arm64': 0.54.0
|
||||
'@oxfmt/binding-darwin-x64': 0.54.0
|
||||
'@oxfmt/binding-freebsd-x64': 0.54.0
|
||||
'@oxfmt/binding-linux-arm-gnueabihf': 0.54.0
|
||||
'@oxfmt/binding-linux-arm-musleabihf': 0.54.0
|
||||
'@oxfmt/binding-linux-arm64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-arm64-musl': 0.54.0
|
||||
'@oxfmt/binding-linux-ppc64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-riscv64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-riscv64-musl': 0.54.0
|
||||
'@oxfmt/binding-linux-s390x-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-x64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-x64-musl': 0.54.0
|
||||
'@oxfmt/binding-openharmony-arm64': 0.54.0
|
||||
'@oxfmt/binding-win32-arm64-msvc': 0.54.0
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.54.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.54.0
|
||||
'@oxfmt/binding-android-arm-eabi': 0.47.0
|
||||
'@oxfmt/binding-android-arm64': 0.47.0
|
||||
'@oxfmt/binding-darwin-arm64': 0.47.0
|
||||
'@oxfmt/binding-darwin-x64': 0.47.0
|
||||
'@oxfmt/binding-freebsd-x64': 0.47.0
|
||||
'@oxfmt/binding-linux-arm-gnueabihf': 0.47.0
|
||||
'@oxfmt/binding-linux-arm-musleabihf': 0.47.0
|
||||
'@oxfmt/binding-linux-arm64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-arm64-musl': 0.47.0
|
||||
'@oxfmt/binding-linux-ppc64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-riscv64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-riscv64-musl': 0.47.0
|
||||
'@oxfmt/binding-linux-s390x-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-x64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-x64-musl': 0.47.0
|
||||
'@oxfmt/binding-openharmony-arm64': 0.47.0
|
||||
'@oxfmt/binding-win32-arm64-msvc': 0.47.0
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.47.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.47.0
|
||||
|
||||
oxlint-tsgolint@0.23.0:
|
||||
oxlint-tsgolint@0.22.1:
|
||||
optionalDependencies:
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.23.0
|
||||
'@oxlint-tsgolint/darwin-x64': 0.23.0
|
||||
'@oxlint-tsgolint/linux-arm64': 0.23.0
|
||||
'@oxlint-tsgolint/linux-x64': 0.23.0
|
||||
'@oxlint-tsgolint/win32-arm64': 0.23.0
|
||||
'@oxlint-tsgolint/win32-x64': 0.23.0
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.22.1
|
||||
'@oxlint-tsgolint/darwin-x64': 0.22.1
|
||||
'@oxlint-tsgolint/linux-arm64': 0.22.1
|
||||
'@oxlint-tsgolint/linux-x64': 0.22.1
|
||||
'@oxlint-tsgolint/win32-arm64': 0.22.1
|
||||
'@oxlint-tsgolint/win32-x64': 0.22.1
|
||||
|
||||
oxlint@1.69.0(oxlint-tsgolint@0.23.0):
|
||||
oxlint@1.62.0(oxlint-tsgolint@0.22.1):
|
||||
optionalDependencies:
|
||||
'@oxlint/binding-android-arm-eabi': 1.69.0
|
||||
'@oxlint/binding-android-arm64': 1.69.0
|
||||
'@oxlint/binding-darwin-arm64': 1.69.0
|
||||
'@oxlint/binding-darwin-x64': 1.69.0
|
||||
'@oxlint/binding-freebsd-x64': 1.69.0
|
||||
'@oxlint/binding-linux-arm-gnueabihf': 1.69.0
|
||||
'@oxlint/binding-linux-arm-musleabihf': 1.69.0
|
||||
'@oxlint/binding-linux-arm64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-arm64-musl': 1.69.0
|
||||
'@oxlint/binding-linux-ppc64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-riscv64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-riscv64-musl': 1.69.0
|
||||
'@oxlint/binding-linux-s390x-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-x64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-x64-musl': 1.69.0
|
||||
'@oxlint/binding-openharmony-arm64': 1.69.0
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.69.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.69.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.69.0
|
||||
oxlint-tsgolint: 0.23.0
|
||||
'@oxlint/binding-android-arm-eabi': 1.62.0
|
||||
'@oxlint/binding-android-arm64': 1.62.0
|
||||
'@oxlint/binding-darwin-arm64': 1.62.0
|
||||
'@oxlint/binding-darwin-x64': 1.62.0
|
||||
'@oxlint/binding-freebsd-x64': 1.62.0
|
||||
'@oxlint/binding-linux-arm-gnueabihf': 1.62.0
|
||||
'@oxlint/binding-linux-arm-musleabihf': 1.62.0
|
||||
'@oxlint/binding-linux-arm64-gnu': 1.62.0
|
||||
'@oxlint/binding-linux-arm64-musl': 1.62.0
|
||||
'@oxlint/binding-linux-ppc64-gnu': 1.62.0
|
||||
'@oxlint/binding-linux-riscv64-gnu': 1.62.0
|
||||
'@oxlint/binding-linux-riscv64-musl': 1.62.0
|
||||
'@oxlint/binding-linux-s390x-gnu': 1.62.0
|
||||
'@oxlint/binding-linux-x64-gnu': 1.62.0
|
||||
'@oxlint/binding-linux-x64-musl': 1.62.0
|
||||
'@oxlint/binding-openharmony-arm64': 1.62.0
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.62.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.62.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.62.0
|
||||
oxlint-tsgolint: 0.22.1
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
@@ -18487,7 +18476,7 @@ snapshots:
|
||||
unist-util-stringify-position: 4.0.0
|
||||
vfile-message: 4.0.2
|
||||
|
||||
vite-plugin-checker@0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(stylelint@17.7.0(typescript@5.9.3))(typescript@5.9.3):
|
||||
vite-plugin-checker@0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.62.0(oxlint-tsgolint@0.22.1))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(stylelint@17.7.0(typescript@5.9.3))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
chokidar: 4.0.3
|
||||
@@ -18502,7 +18491,7 @@ snapshots:
|
||||
eslint: 10.2.1(jiti@2.6.1)
|
||||
meow: 13.2.0
|
||||
optionator: 0.9.4
|
||||
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
|
||||
oxlint: 1.62.0(oxlint-tsgolint@0.22.1)
|
||||
stylelint: 17.7.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
|
||||
@@ -323,3 +323,10 @@ export const AIAssistantPage = Loadable(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IntegrationsDetailsPage,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -507,6 +508,13 @@ const routes: AppRoutes[] = [
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
exact: true,
|
||||
component: LLMObservabilityModelPricingPage,
|
||||
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
@@ -18,8 +18,6 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CloneDashboardV2201,
|
||||
CloneDashboardV2PathParameters,
|
||||
CreateDashboardV2201,
|
||||
CreatePublicDashboard201,
|
||||
CreatePublicDashboardPathParameters,
|
||||
@@ -65,7 +63,7 @@ export const deletePublicDashboard = (
|
||||
{ id }: DeletePublicDashboardPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/dashboards/${id}/public`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -348,7 +346,7 @@ export const updatePublicDashboard = (
|
||||
dashboardtypesUpdatablePublicDashboardDTO?: BodyType<DashboardtypesUpdatablePublicDashboardDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/dashboards/${id}/public`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -838,7 +836,7 @@ export const deleteDashboardV2 = (
|
||||
{ id }: DeleteDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1208,85 +1206,6 @@ export const useUpdateDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getUpdateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint clones an existing v2-shape dashboard. User and integration dashboards can be cloned; system dashboards are rejected. The clone keeps the source's display name, panels, and tags, but gets a freshly generated unique internal name and is always created as an unlocked user dashboard owned by the caller.
|
||||
* @summary Clone dashboard (v2)
|
||||
*/
|
||||
export const cloneDashboardV2 = (
|
||||
{ id }: CloneDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CloneDashboardV2201>({
|
||||
url: `/api/v2/dashboards/${id}/clone`,
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCloneDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: CloneDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: CloneDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['cloneDashboardV2'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>,
|
||||
{ pathParams: CloneDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return cloneDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CloneDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>
|
||||
>;
|
||||
|
||||
export type CloneDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Clone dashboard (v2)
|
||||
*/
|
||||
export const useCloneDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: CloneDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof cloneDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: CloneDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCloneDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Unlock dashboard (v2)
|
||||
@@ -1295,7 +1214,7 @@ export const unlockDashboardV2 = (
|
||||
{ id }: UnlockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1374,7 +1293,7 @@ export const lockDashboardV2 = (
|
||||
{ id }: LockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
@@ -1552,7 +1471,7 @@ export const unpinDashboardV2 = (
|
||||
{ id }: UnpinDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/users/me/dashboards/${id}/pins`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1631,7 +1550,7 @@ export const pinDashboardV2 = (
|
||||
{ id }: PinDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/users/me/dashboards/${id}/pins`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const handleExportRawDataPOST = (
|
||||
params?: HandleExportRawDataPOSTParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/export_raw_data`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -680,7 +680,7 @@ export const updateMetricMetadata = (
|
||||
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/metrics/${metricName}/metadata`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -203,7 +203,7 @@ export const deleteRole = (
|
||||
{ id }: DeleteRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -372,7 +372,7 @@ export const patchRole = (
|
||||
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -572,7 +572,7 @@ export const patchObjects = (
|
||||
coretypesPatchableObjectsDTO?: BodyType<CoretypesPatchableObjectsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -222,7 +222,7 @@ export const deleteServiceAccount = (
|
||||
{ id }: DeleteServiceAccountPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -405,7 +405,7 @@ export const updateServiceAccount = (
|
||||
serviceaccounttypesPostableServiceAccountDTO?: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -707,7 +707,7 @@ export const revokeServiceAccountKey = (
|
||||
{ id, fid }: RevokeServiceAccountKeyPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -788,7 +788,7 @@ export const updateServiceAccountKey = (
|
||||
serviceaccounttypesUpdatableFactorAPIKeyDTO?: BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1090,7 +1090,7 @@ export const deleteServiceAccountRole = (
|
||||
{ id, rid }: DeleteServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1254,7 +1254,7 @@ export const updateMyServiceAccount = (
|
||||
serviceaccounttypesPostableServiceAccountDTO?: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/me`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -413,11 +413,21 @@ export interface AlertmanagertypesRecurrenceDTO {
|
||||
* @type string
|
||||
*/
|
||||
duration: string;
|
||||
/**
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
endTime?: string | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
|
||||
repeatType: AlertmanagertypesRepeatTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesScheduleDTO {
|
||||
@@ -431,7 +441,7 @@ export interface AlertmanagertypesScheduleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime: string;
|
||||
startTime?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2143,10 +2153,6 @@ export interface ErrorsResponseerroradditionalDTO {
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
export interface ErrorsResponseretryjsonDTO {
|
||||
@@ -2162,6 +2168,10 @@ export interface ErrorsJSONDTO {
|
||||
* @type array
|
||||
*/
|
||||
errors?: ErrorsResponseerroradditionalDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
invalidReferences?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2645,14 +2655,6 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
appservice = 'appservice',
|
||||
containerapp = 'containerapp',
|
||||
aks = 'aks',
|
||||
sqldatabase = 'sqldatabase',
|
||||
sqldatabasemi = 'sqldatabasemi',
|
||||
mysqlflexibleserver = 'mysqlflexibleserver',
|
||||
postgresqlflexibleserver = 'postgresqlflexibleserver',
|
||||
mongodb = 'mongodb',
|
||||
cosmosdb = 'cosmosdb',
|
||||
cassandradb = 'cassandradb',
|
||||
redis = 'redis',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -3154,6 +3156,17 @@ export interface DashboardLinkDTO {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface DashboardPanelDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface VariableDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3879,17 +3892,6 @@ export type DashboardtypesDashboardSpecDTODatasources = {
|
||||
export enum DashboardtypesPanelKindDTO {
|
||||
Panel = 'Panel',
|
||||
}
|
||||
export interface DashboardtypesDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
}
|
||||
@@ -4438,36 +4440,42 @@ export interface DashboardtypesQuerySpecDTO {
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
plugin: DashboardtypesQueryPluginDTO;
|
||||
plugin?: DashboardtypesQueryPluginDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesQueryDTO {
|
||||
kind: Querybuildertypesv5RequestTypeDTO;
|
||||
spec: DashboardtypesQuerySpecDTO;
|
||||
kind?: Querybuildertypesv5RequestTypeDTO;
|
||||
spec?: DashboardtypesQuerySpecDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelSpecDTO {
|
||||
display: DashboardtypesDisplayDTO;
|
||||
display?: DashboardPanelDisplayDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
links?: DashboardLinkDTO[];
|
||||
plugin: DashboardtypesPanelPluginDTO;
|
||||
plugin?: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
queries: DashboardtypesQueryDTO[] | null;
|
||||
queries?: DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelDTO {
|
||||
kind: DashboardtypesPanelKindDTO;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
kind?: DashboardtypesPanelKindDTO;
|
||||
spec?: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesDashboardSpecDTOPanels = {
|
||||
export type DashboardtypesDashboardSpecDTOPanelsAnyOf = {
|
||||
[key: string]: DashboardtypesPanelDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesDashboardSpecDTOPanels =
|
||||
DashboardtypesDashboardSpecDTOPanelsAnyOf | null;
|
||||
|
||||
export enum DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind {
|
||||
Grid = 'Grid',
|
||||
}
|
||||
@@ -4564,7 +4572,7 @@ export interface DashboardtypesListVariableSpecDTO {
|
||||
*/
|
||||
customAllValue?: string;
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
display?: VariableDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4606,23 +4614,23 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
* @type object
|
||||
*/
|
||||
datasources?: DashboardtypesDashboardSpecDTODatasources;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
display?: CommonDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
duration?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
layouts: DashboardtypesLayoutDTO[];
|
||||
layouts?: DashboardtypesLayoutDTO[] | null;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
links?: DashboardLinkDTO[];
|
||||
/**
|
||||
* @type object
|
||||
* @type object,null
|
||||
*/
|
||||
panels: DashboardtypesDashboardSpecDTOPanels;
|
||||
panels?: DashboardtypesDashboardSpecDTOPanels;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4630,7 +4638,7 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
variables: DashboardtypesVariableDTO[];
|
||||
variables?: DashboardtypesVariableDTO[];
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginKindDTO {
|
||||
@@ -4754,7 +4762,7 @@ export enum DashboardtypesListSortDTO {
|
||||
name = 'name',
|
||||
}
|
||||
export interface DashboardtypesListedDashboardV2SpecDTO {
|
||||
display?: DashboardtypesDisplayDTO;
|
||||
display?: CommonDisplayDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesListedDashboardForUserV2DTO {
|
||||
@@ -9744,19 +9752,6 @@ export type UpdateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type GetStats200Data = { [key: string]: unknown };
|
||||
|
||||
export type GetStats200 = {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
data: GetStats200Data;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceAggregationsPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
@@ -9915,17 +9910,6 @@ export type UpdateDashboardV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CloneDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CloneDashboardV2201 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type { GetStats200, RenderErrorResponseDTO } from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* This endpoint returns the collected stats for the organization
|
||||
* @summary Get stats
|
||||
*/
|
||||
export const getStats = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetStats200>({
|
||||
url: `/api/v1/stats`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetStatsQueryKey = () => {
|
||||
return [`/api/v1/stats`] as const;
|
||||
};
|
||||
|
||||
export const getGetStatsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getStats>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getStats>>, TError, TData>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetStatsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getStats>>> = ({
|
||||
signal,
|
||||
}) => getStats(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getStats>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetStatsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getStats>>
|
||||
>;
|
||||
export type GetStatsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get stats
|
||||
*/
|
||||
|
||||
export function useGetStats<
|
||||
TData = Awaited<ReturnType<typeof getStats>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getStats>>, TError, TData>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetStatsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get stats
|
||||
*/
|
||||
export const invalidateGetStats = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetStatsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
@@ -36,7 +36,6 @@ export const REACT_QUERY_KEY = {
|
||||
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
|
||||
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
|
||||
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
|
||||
GET_TRACE_V3_FLAMEGRAPH: 'GET_TRACE_V3_FLAMEGRAPH',
|
||||
GET_POD_LIST: 'GET_POD_LIST',
|
||||
GET_NODE_LIST: 'GET_NODE_LIST',
|
||||
GET_DEPLOYMENT_LIST: 'GET_DEPLOYMENT_LIST',
|
||||
|
||||
@@ -91,6 +91,7 @@ const ROUTES = {
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/model-pricing',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -40,31 +40,13 @@ type SpeechRecognitionConstructor = new () => ISpeechRecognition;
|
||||
|
||||
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
|
||||
|
||||
// Some hardened/enterprise browsers install a getter
|
||||
// on window.SpeechRecognition that THROWS on access ("Web Speech API is disabled
|
||||
// due to your security policy") instead of leaving the property undefined.
|
||||
// Because this resolves at module-evaluation time, an uncaught throw here aborts
|
||||
// the entire bundle and the app renders a blank page. Read defensively so a
|
||||
// throwing getter degrades to "unsupported" rather than crashing the app.
|
||||
function resolveSpeechRecognitionAPI(): SpeechRecognitionConstructor | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).SpeechRecognition ??
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).webkitSpeechRecognition ??
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
|
||||
resolveSpeechRecognitionAPI();
|
||||
typeof window !== 'undefined'
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((window as any).SpeechRecognition ??
|
||||
(window as any).webkitSpeechRecognition ??
|
||||
null)
|
||||
: null;
|
||||
|
||||
export type SpeechRecognitionError =
|
||||
| 'not-supported'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
.billingContainer {
|
||||
margin-bottom: var(--spacing-20);
|
||||
padding-top: 36px;
|
||||
width: 90%;
|
||||
margin: 0 auto var(--spacing-20);
|
||||
margin: 0 auto;
|
||||
|
||||
.pageHeader {
|
||||
margin-bottom: var(--spacing-8);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.license-key-callout {
|
||||
margin: var(--spacing-4) var(--spacing-6);
|
||||
width: auto !important;
|
||||
width: auto;
|
||||
|
||||
.license-key-callout__description {
|
||||
display: flex;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useQueries } from 'react-query';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import GeneralSettings from '../index';
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
const baseQueryResult = {
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isSuccess: true,
|
||||
data: undefined,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
};
|
||||
|
||||
describe('GeneralSettings index', () => {
|
||||
it('renders fallback message when logs query fails with a non-APIError', () => {
|
||||
(useQueries as jest.Mock).mockReturnValue([
|
||||
{ ...baseQueryResult },
|
||||
{ ...baseQueryResult },
|
||||
{
|
||||
...baseQueryResult,
|
||||
isError: true,
|
||||
isSuccess: false,
|
||||
error: new TypeError(
|
||||
"Cannot read properties of undefined (reading 'code')",
|
||||
),
|
||||
},
|
||||
{ ...baseQueryResult },
|
||||
]);
|
||||
|
||||
render(<GeneralSettings />);
|
||||
|
||||
expect(screen.getByText('something_went_wrong')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -76,9 +76,7 @@ function GeneralSettings(): JSX.Element {
|
||||
if (getRetentionPeriodLogsApiResponse.isError || getDisksResponse.isError) {
|
||||
return (
|
||||
<Typography>
|
||||
{(getRetentionPeriodLogsApiResponse.error instanceof APIError
|
||||
? getRetentionPeriodLogsApiResponse.error.getErrorMessage()
|
||||
: undefined) ||
|
||||
{(getRetentionPeriodLogsApiResponse.error as APIError).getErrorMessage() ||
|
||||
getDisksResponse.data?.error ||
|
||||
t('something_went_wrong')}
|
||||
</Typography>
|
||||
|
||||
@@ -796,7 +796,7 @@ export const getClusterMetricsQueryPayload = (
|
||||
key: k8sDeploymentDesiredKey,
|
||||
type: 'Gauge',
|
||||
},
|
||||
aggregateOperator: 'latest',
|
||||
aggregateOperator: 'avg',
|
||||
dataSource: DataSource.METRICS,
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
@@ -839,7 +839,7 @@ export const getClusterMetricsQueryPayload = (
|
||||
reduceTo: ReduceOperators.LAST,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'latest',
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
|
||||
@@ -40,7 +40,6 @@ import { K8S_ENTITY_EVENTS_EXPRESSION_KEY, useEntityEvents } from './hooks';
|
||||
import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
|
||||
|
||||
import styles from './EntityEvents.module.scss';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
interface EventDataType {
|
||||
key: string;
|
||||
@@ -168,25 +167,17 @@ function EntityEventsContent({
|
||||
[events],
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const columns: TableColumnsType<EventDataType> = useMemo(
|
||||
() => [
|
||||
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
|
||||
{
|
||||
title: 'Timestamp',
|
||||
dataIndex: 'timestamp',
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
key: 'timestamp',
|
||||
render: (value: string | number): string =>
|
||||
formatTimezoneAdjustedTimestamp(
|
||||
typeof value === 'string' ? value : value / 1e6,
|
||||
),
|
||||
},
|
||||
{ title: 'Body', dataIndex: 'body', key: 'body' },
|
||||
],
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
const columns: TableColumnsType<EventDataType> = [
|
||||
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
|
||||
{
|
||||
title: 'Timestamp',
|
||||
dataIndex: 'timestamp',
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
key: 'timestamp',
|
||||
},
|
||||
{ title: 'Body', dataIndex: 'body', key: 'body' },
|
||||
];
|
||||
|
||||
const handleExpandRowIcon = ({
|
||||
expanded,
|
||||
|
||||
@@ -41,7 +41,6 @@ import { getTraceListColumns } from './traceListColumns';
|
||||
import { getEntityTracesQueryPayload } from './utils';
|
||||
|
||||
import styles from './EntityTraces.module.scss';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
@@ -137,11 +136,7 @@ function EntityTracesContent({
|
||||
[timeRange.startTime, timeRange.endTime, userExpression],
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const traceListColumns = getTraceListColumns(
|
||||
selectedEntityTracesColumns,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
const traceListColumns = getTraceListColumns(selectedEntityTracesColumns);
|
||||
|
||||
const isKeyNotFound = isKeyNotFoundError(error);
|
||||
const isDataEmpty =
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import {
|
||||
BlockLink,
|
||||
getTraceLink,
|
||||
} from 'container/TracesExplorer/ListView/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { FormatTimezoneAdjustedTimestamp } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
|
||||
const keyToLabelMap: Record<string, string> = {
|
||||
timestamp: 'Timestamp',
|
||||
@@ -58,7 +59,6 @@ const getValueForKey = (data: Record<string, any>, key: string): any => {
|
||||
|
||||
export const getTraceListColumns = (
|
||||
selectedColumns: BaseAutocompleteData[],
|
||||
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp,
|
||||
): ColumnsType<RowData> => {
|
||||
const columns: ColumnsType<RowData> =
|
||||
selectedColumns.map(({ dataType, key, type }) => ({
|
||||
@@ -73,8 +73,8 @@ export const getTraceListColumns = (
|
||||
if (primaryKey === 'timestamp') {
|
||||
const date =
|
||||
typeof value === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(value)
|
||||
: formatTimezoneAdjustedTimestamp(value / 1e6);
|
||||
? dayjs(value).format(DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: dayjs(value / 1e6).format(DATE_TIME_FORMATS.ISO_DATETIME_MS);
|
||||
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
|
||||
@@ -1366,7 +1366,7 @@ export const getPodMetricsQueryPayload = (
|
||||
orderBy: [],
|
||||
queryName: 'B',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
spaceAggregation: 'avg',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
|
||||
@@ -86,9 +86,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
|
||||
},
|
||||
{
|
||||
id: 'capacity',
|
||||
header: 'Capacity',
|
||||
header: 'Volume Capacity',
|
||||
accessorFn: (row): number => row.volumeCapacity,
|
||||
width: { min: 140 },
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const capacity = value as number;
|
||||
@@ -105,9 +105,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
|
||||
},
|
||||
{
|
||||
id: 'usage',
|
||||
header: 'Used',
|
||||
header: 'Volume Utilization',
|
||||
accessorFn: (row): number => row.volumeUsage,
|
||||
width: { min: 140 },
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const usage = value as number;
|
||||
@@ -124,9 +124,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
|
||||
},
|
||||
{
|
||||
id: 'available',
|
||||
header: 'Available',
|
||||
header: 'Volume Available',
|
||||
accessorFn: (row): number => row.volumeAvailable,
|
||||
width: { min: 140 },
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const available = value as number;
|
||||
@@ -141,61 +141,4 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inodes',
|
||||
header: 'Inodes',
|
||||
accessorFn: (row): number => row.volumeInodes,
|
||||
width: { min: 140 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const inodes = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={inodes}
|
||||
entity={InfraMonitoringEntity.VOLUMES}
|
||||
attribute="inodes metric"
|
||||
>
|
||||
<TanStackTable.Text>{inodes}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inodesUsed',
|
||||
header: 'Inodes Used',
|
||||
accessorFn: (row): number => row.volumeInodesUsed,
|
||||
width: { min: 160 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const inodesUsed = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={inodesUsed}
|
||||
entity={InfraMonitoringEntity.VOLUMES}
|
||||
attribute="inodes used metric"
|
||||
>
|
||||
<TanStackTable.Text>{inodesUsed}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inodesFree',
|
||||
header: 'Inodes Free',
|
||||
accessorFn: (row): number => row.volumeInodesFree,
|
||||
width: { min: 160 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const inodesFree = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={inodesFree}
|
||||
entity={InfraMonitoringEntity.VOLUMES}
|
||||
attribute="inodes free metric"
|
||||
>
|
||||
<TanStackTable.Text>{inodesFree}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
.llm-observability-model-pricing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px 32px;
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
&__title {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-tabs {
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
&__search {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
&__source,
|
||||
&__currency {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
&__add {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.page-error {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 90, 90, 0.08);
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-costs-table {
|
||||
.ant-table-thead > tr > th {
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.model-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
&__name {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__canonical-id {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.price-cell {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.extra-buckets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
&__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__key {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__price {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
margin: 0;
|
||||
|
||||
&--auto {
|
||||
background: rgba(78, 116, 248, 0.12);
|
||||
color: var(--bg-robin-400);
|
||||
border-color: rgba(78, 116, 248, 0.24);
|
||||
}
|
||||
|
||||
&--override {
|
||||
background: rgba(245, 175, 25, 0.12);
|
||||
color: var(--bg-amber-400);
|
||||
border-color: rgba(245, 175, 25, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
&__row--selected {
|
||||
background: rgba(78, 116, 248, 0.06);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Plus, Search } from '@signozhq/icons';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import ModelCostDrawer from './ModelCostDrawer';
|
||||
import ModelCostsTable from './ModelCostsTable';
|
||||
import { useModelCostDrawer } from './useModelCostDrawer';
|
||||
import { filterRules, type PricingRule, type SourceFilter } from './utils';
|
||||
|
||||
import './LLMObservabilityModelPricing.styles.scss';
|
||||
|
||||
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'Source: All' },
|
||||
{ value: 'auto', label: 'Auto-populated' },
|
||||
{ value: 'override', label: 'User override' },
|
||||
];
|
||||
|
||||
const CURRENCY_OPTIONS = [
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'EUR', label: 'EUR', disabled: true },
|
||||
{ value: 'INR', label: 'INR', disabled: true },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
function LLMObservabilityModelPricing(): JSX.Element {
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [source, setSource] = useState<SourceFilter>('all');
|
||||
const [currency, setCurrency] = useState<string>('USD');
|
||||
|
||||
const { data, isLoading, isError } = useListLLMPricingRules({
|
||||
offset: 0,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
|
||||
|
||||
const filteredRules = useMemo(
|
||||
() => filterRules(rules, search, source),
|
||||
[rules, search, source],
|
||||
);
|
||||
|
||||
const drawer = useModelCostDrawer();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="llm-observability-model-pricing"
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<header className="page-header">
|
||||
<div className="page-header__title">
|
||||
<h1>Configuration</h1>
|
||||
<p>Model pricing and cost estimation settings</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
className="page-tabs"
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{ key: 'model-costs', label: 'Model costs', children: null },
|
||||
{
|
||||
key: 'unpriced-models',
|
||||
label: 'Unpriced models',
|
||||
disabled: true,
|
||||
children: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="filters-bar">
|
||||
<Input
|
||||
className="filters-bar__search"
|
||||
placeholder="Search by model or provider…"
|
||||
prefix={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(event): void => setSearch(event.target.value)}
|
||||
testId="search-input"
|
||||
/>
|
||||
<SelectSimple
|
||||
className="filters-bar__source"
|
||||
value={source}
|
||||
onChange={(value): void => setSource(value as SourceFilter)}
|
||||
items={SOURCE_OPTIONS}
|
||||
testId="source-select"
|
||||
/>
|
||||
<SelectSimple
|
||||
className="filters-bar__currency"
|
||||
value={currency}
|
||||
onChange={(value): void => setCurrency(value as string)}
|
||||
items={CURRENCY_OPTIONS}
|
||||
testId="currency-select"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="filters-bar__add"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => drawer.openForAdd()}
|
||||
testId="add-model-cost-btn"
|
||||
>
|
||||
Add model cost
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="page-error" role="alert">
|
||||
Failed to load pricing rules. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelCostsTable
|
||||
rules={filteredRules}
|
||||
isLoading={isLoading}
|
||||
selectedRuleId={drawer.selectedRuleId}
|
||||
onEdit={drawer.openForEdit}
|
||||
/>
|
||||
|
||||
<footer className="page-footer">
|
||||
Showing {filteredRules.length} model{filteredRules.length === 1 ? '' : 's'}
|
||||
{' · '}All prices per 1M tokens (USD)
|
||||
</footer>
|
||||
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
draft={drawer.draft}
|
||||
setDraft={drawer.setDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={drawer.save}
|
||||
onDelete={drawer.deleteRule}
|
||||
isSaving={drawer.isSaving}
|
||||
isDeleting={drawer.isDeleting}
|
||||
saveError={drawer.saveError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityModelPricing;
|
||||
@@ -0,0 +1,305 @@
|
||||
.model-cost-drawer {
|
||||
.ant-drawer-body {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
|
||||
&-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
label,
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
.help {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawer-surface {
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.managed-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.pattern-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.pattern-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.pattern-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&__remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pattern-add {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.help {
|
||||
code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.source-radio-group {
|
||||
// @signozhq/ui's RadioGroupItem defaults its unchecked border to
|
||||
// --l3-background, which matches the drawer surface and makes the dot
|
||||
// invisible. Override with a contrasting border so users can see the
|
||||
// unchecked state.
|
||||
--radio-group-item-border-color: var(--bg-slate-200);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
// Layout overrides for @signozhq/ui's RadioGroupItem wrapper. The
|
||||
// library injects single-class CSS at runtime (after our bundled
|
||||
// stylesheet loads), so we use a two-class selector to win the
|
||||
// cascade and force the wrapper to lay the dot on the left with the
|
||||
// label text flush beside it.
|
||||
.source-radio {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
|
||||
// The radio button itself: keep it fixed-size and aligned with
|
||||
// the title baseline (margin-top compensates for align-items:
|
||||
// flex-start vs the title's line-box).
|
||||
> button[role='radio'] {
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
// The library wraps children in a <label>. Make it grow into the
|
||||
// remaining width and reset the .drawer-section label typography
|
||||
// leak (set earlier in this file) so the title/desc divs use
|
||||
// their own styles.
|
||||
> label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
|
||||
// Use :has() to highlight the wrapper card when its inner button is checked.
|
||||
&.source-radio--auto:has(button[data-state='checked']) {
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
border-color: rgba(78, 116, 248, 0.3);
|
||||
}
|
||||
|
||||
&.source-radio--override:has(button[data-state='checked']) {
|
||||
background: rgba(245, 175, 25, 0.1);
|
||||
border-color: rgba(245, 175, 25, 0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reset-confirm {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(78, 116, 248, 0.06);
|
||||
border: 1px solid rgba(78, 116, 248, 0.2);
|
||||
|
||||
p {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pricing-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cache-mode-field {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.extras-divider {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.cost-preview {
|
||||
&__line {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
font-size: 12px;
|
||||
|
||||
strong {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 90, 90, 0.08);
|
||||
color: var(--bg-cherry-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useState } from 'react';
|
||||
import { InputNumber } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Lock, Trash2, X } from '@signozhq/icons';
|
||||
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
CACHE_MODE_OPTIONS,
|
||||
PROVIDER_OPTIONS,
|
||||
validateDraft,
|
||||
type DrawerDraft,
|
||||
type DrawerMode,
|
||||
} from './drawerUtils';
|
||||
import './ModelCostDrawer.styles.scss';
|
||||
|
||||
interface ModelCostDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: DrawerMode;
|
||||
draft: DrawerDraft;
|
||||
setDraft: (next: DrawerDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
function ModelCostDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
}: ModelCostDrawerProps): JSX.Element {
|
||||
const [patternInput, setPatternInput] = useState<string>('');
|
||||
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
|
||||
const isReadOnly = !draft.isOverride;
|
||||
|
||||
const validation = validateDraft(draft, mode);
|
||||
|
||||
const update = (patch: Partial<DrawerDraft>): void => {
|
||||
setDraft({ ...draft, ...patch });
|
||||
};
|
||||
|
||||
const updatePricing = (patch: Partial<DrawerDraft['pricing']>): void => {
|
||||
setDraft({ ...draft, pricing: { ...draft.pricing, ...patch } });
|
||||
};
|
||||
|
||||
const addPattern = (): void => {
|
||||
const next = patternInput.trim();
|
||||
if (!next || draft.patterns.includes(next)) {
|
||||
setPatternInput('');
|
||||
return;
|
||||
}
|
||||
update({ patterns: [...draft.patterns, next] });
|
||||
setPatternInput('');
|
||||
};
|
||||
|
||||
const removePattern = (pattern: string): void => {
|
||||
update({ patterns: draft.patterns.filter((p) => p !== pattern) });
|
||||
};
|
||||
|
||||
const handleSourceChange = (value: 'auto' | 'override'): void => {
|
||||
if (value === 'auto' && draft.isOverride) {
|
||||
setShowResetConfirm(true);
|
||||
return;
|
||||
}
|
||||
if (value === 'override' && !draft.isOverride) {
|
||||
update({ isOverride: true });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReset = (): void => {
|
||||
update({ isOverride: false });
|
||||
setShowResetConfirm(false);
|
||||
};
|
||||
|
||||
const hasCacheBucket =
|
||||
draft.pricing.cacheRead !== null || draft.pricing.cacheWrite !== null;
|
||||
|
||||
const footer = (
|
||||
<div className="model-cost-drawer__footer">
|
||||
{mode === 'edit' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
loading={isDeleting}
|
||||
testId="drawer-delete-btn"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<div className="model-cost-drawer__footer-right">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="drawer-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{!validation.ok && validation.message ? (
|
||||
<TooltipSimple title={validation.message} withPortal={false}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
loading={isSaving}
|
||||
disabled
|
||||
testId="drawer-save-btn"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
loading={isSaving}
|
||||
testId="drawer-save-btn"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
width="base"
|
||||
className="model-cost-drawer"
|
||||
footer={footer}
|
||||
title={mode === 'edit' ? 'Edit model cost' : 'Add model cost'}
|
||||
subTitle="Pricing computes gen_ai.estimated_total_cost at ingest."
|
||||
drawerHeaderProps={{ className: 'model-cost-drawer__title' }}
|
||||
>
|
||||
<div className="drawer-section">
|
||||
<label htmlFor="billing-model-id">Billing model ID</label>
|
||||
<Input
|
||||
id="billing-model-id"
|
||||
placeholder="e.g. openai:gpt-4o"
|
||||
value={draft.modelName}
|
||||
disabled={mode === 'edit' || isReadOnly}
|
||||
onChange={(e): void => update({ modelName: e.target.value })}
|
||||
testId="drawer-model-id-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section">
|
||||
<label htmlFor="provider-select">Provider</label>
|
||||
<SelectSimple
|
||||
id="provider-select"
|
||||
value={draft.provider}
|
||||
onChange={(value): void => update({ provider: value as string })}
|
||||
items={PROVIDER_OPTIONS}
|
||||
disabled={mode === 'edit'}
|
||||
className="full-width"
|
||||
testId="drawer-provider-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section">
|
||||
<span className="field-label">
|
||||
Model name patterns <span className="muted">(prefix match)</span>
|
||||
</span>
|
||||
<div className="pattern-box">
|
||||
<div className="pattern-chips">
|
||||
{draft.patterns.map((pattern) => (
|
||||
<Badge
|
||||
key={pattern}
|
||||
color="vanilla"
|
||||
variant="outline"
|
||||
className="pattern-chip"
|
||||
>
|
||||
{pattern}*
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove pattern ${pattern}`}
|
||||
className="pattern-chip__remove"
|
||||
onClick={(): void => removePattern(pattern)}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<div className="pattern-add">
|
||||
<Input
|
||||
placeholder="Add pattern…"
|
||||
value={patternInput}
|
||||
onChange={(e): void => setPatternInput(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addPattern();
|
||||
}
|
||||
}}
|
||||
testId="drawer-pattern-input"
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={addPattern}
|
||||
testId="drawer-pattern-add-btn"
|
||||
>
|
||||
+ Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted help">
|
||||
Each pattern uses <strong>prefix matching</strong> against{' '}
|
||||
<code>gen_ai.request.model</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section drawer-surface">
|
||||
<div className="drawer-surface__head">
|
||||
<h4>Source</h4>
|
||||
{isReadOnly && (
|
||||
<span className="managed-label" data-testid="drawer-managed-label">
|
||||
<Lock size={12} />
|
||||
Managed by SigNoz
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={draft.isOverride ? 'override' : 'auto'}
|
||||
onChange={(value): void =>
|
||||
handleSourceChange(value as 'auto' | 'override')
|
||||
}
|
||||
className="source-radio-group"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="auto"
|
||||
containerClassName="source-radio source-radio--auto"
|
||||
testId="drawer-source-auto"
|
||||
>
|
||||
<div className="source-radio__title">Auto-populated</div>
|
||||
<div className="source-radio__desc">Default pricing from SigNoz.</div>
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
value="override"
|
||||
containerClassName="source-radio source-radio--override"
|
||||
testId="drawer-source-override"
|
||||
>
|
||||
<div className="source-radio__title">User override</div>
|
||||
<div className="source-radio__desc">
|
||||
Custom pricing. Takes precedence.
|
||||
</div>
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
{showResetConfirm && (
|
||||
<div
|
||||
className="reset-confirm"
|
||||
role="dialog"
|
||||
aria-label="Reset to default pricing"
|
||||
>
|
||||
<p>Reset to default pricing? Custom values will be discarded.</p>
|
||||
<div className="reset-confirm__actions">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={(): void => setShowResetConfirm(false)}
|
||||
testId="drawer-reset-keep-btn"
|
||||
>
|
||||
Keep
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={confirmReset}
|
||||
testId="drawer-reset-confirm-btn"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="drawer-section drawer-surface">
|
||||
<div className="drawer-surface__head">
|
||||
<h4>Pricing (per 1M tokens, USD)</h4>
|
||||
{isReadOnly && (
|
||||
<span className="managed-label" data-testid="drawer-readonly-label">
|
||||
<Lock size={12} />
|
||||
Read-only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pricing-grid">
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="input-cost">
|
||||
Input cost <span className="required">*</span>
|
||||
</label>
|
||||
<InputNumber
|
||||
id="input-cost"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={draft.pricing.input}
|
||||
onChange={(v): void => updatePricing({ input: Number(v) || 0 })}
|
||||
disabled={isReadOnly}
|
||||
data-testid="drawer-input-cost"
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="output-cost">
|
||||
Output cost <span className="required">*</span>
|
||||
</label>
|
||||
<InputNumber
|
||||
id="output-cost"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={draft.pricing.output}
|
||||
onChange={(v): void => updatePricing({ output: Number(v) || 0 })}
|
||||
disabled={isReadOnly}
|
||||
data-testid="drawer-output-cost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="extras-divider">Extra pricing buckets</div>
|
||||
<div className="pricing-grid">
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="cache-read">cache_read</label>
|
||||
<InputNumber
|
||||
id="cache-read"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={draft.pricing.cacheRead ?? undefined}
|
||||
placeholder="—"
|
||||
onChange={(v): void =>
|
||||
updatePricing({ cacheRead: v === null ? null : Number(v) })
|
||||
}
|
||||
disabled={isReadOnly}
|
||||
data-testid="drawer-cache-read-cost"
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-field">
|
||||
<label htmlFor="cache-write">cache_write</label>
|
||||
<InputNumber
|
||||
id="cache-write"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={draft.pricing.cacheWrite ?? undefined}
|
||||
placeholder="—"
|
||||
onChange={(v): void =>
|
||||
updatePricing({ cacheWrite: v === null ? null : Number(v) })
|
||||
}
|
||||
disabled={isReadOnly}
|
||||
data-testid="drawer-cache-write-cost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasCacheBucket && (
|
||||
<div className="pricing-field cache-mode-field">
|
||||
<label htmlFor="cache-mode">Cache mode</label>
|
||||
<SelectSimple
|
||||
id="cache-mode"
|
||||
value={draft.pricing.cacheMode}
|
||||
items={CACHE_MODE_OPTIONS}
|
||||
onChange={(v): void => updatePricing({ cacheMode: v as CacheModeDTO })}
|
||||
disabled={isReadOnly}
|
||||
className="full-width"
|
||||
testId="drawer-cache-mode"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="muted help">Image tokens may be priced differently (v2).</p>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="drawer-error" role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostDrawer;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Table, type TableColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
getRelativeLastSeen,
|
||||
getSourceLabel,
|
||||
type PricingRule,
|
||||
} from './utils';
|
||||
|
||||
interface ModelCostsTableProps {
|
||||
rules: PricingRule[];
|
||||
isLoading: boolean;
|
||||
selectedRuleId: string | null;
|
||||
onEdit: (rule: PricingRule) => void;
|
||||
}
|
||||
|
||||
function ModelCostsTable({
|
||||
rules,
|
||||
isLoading,
|
||||
selectedRuleId,
|
||||
onEdit,
|
||||
}: ModelCostsTableProps): JSX.Element {
|
||||
const columns: TableColumnsType<PricingRule> = [
|
||||
{
|
||||
title: 'Model',
|
||||
dataIndex: 'modelName',
|
||||
key: 'model',
|
||||
render: (_value, rule): JSX.Element => (
|
||||
<div className="model-cell">
|
||||
<div
|
||||
className="model-cell__name"
|
||||
data-testid={`model-cell-name-${rule.id}`}
|
||||
>
|
||||
{rule.modelName}
|
||||
</div>
|
||||
<div
|
||||
className="model-cell__canonical-id"
|
||||
data-testid={`model-cell-canonical-id-${rule.id}`}
|
||||
>
|
||||
{getCanonicalId(rule)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Provider',
|
||||
dataIndex: 'provider',
|
||||
key: 'provider',
|
||||
},
|
||||
{
|
||||
title: 'Input / 1M',
|
||||
key: 'input',
|
||||
render: (_value, rule): JSX.Element => (
|
||||
<span className="price-cell" data-testid={`price-cell-input-${rule.id}`}>
|
||||
{formatPricePerMillion(rule.pricing?.input)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Output / 1M',
|
||||
key: 'output',
|
||||
render: (_value, rule): JSX.Element => (
|
||||
<span className="price-cell" data-testid={`price-cell-output-${rule.id}`}>
|
||||
{formatPricePerMillion(rule.pricing?.output)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Extra buckets',
|
||||
key: 'extra-buckets',
|
||||
render: (_value, rule): JSX.Element => {
|
||||
const buckets = getExtraBuckets(rule);
|
||||
if (buckets.length === 0) {
|
||||
return <span className="muted">—</span>;
|
||||
}
|
||||
return (
|
||||
<div className="extra-buckets">
|
||||
{buckets.map((bucket) => (
|
||||
<Badge
|
||||
key={bucket.key}
|
||||
color="vanilla"
|
||||
variant="outline"
|
||||
className="extra-buckets__chip"
|
||||
>
|
||||
<span className="extra-buckets__key">{bucket.key}</span>
|
||||
<span className="extra-buckets__price">
|
||||
{formatPricePerMillion(bucket.pricePerMillion)}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Source',
|
||||
dataIndex: 'isOverride',
|
||||
key: 'source',
|
||||
render: (_value, rule): JSX.Element => {
|
||||
const label = getSourceLabel(rule);
|
||||
return (
|
||||
<Badge
|
||||
color={rule.isOverride ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
className="source-badge"
|
||||
data-testid={`source-badge-${rule.id}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Last seen',
|
||||
key: 'last-seen',
|
||||
render: (_value, rule): string => getRelativeLastSeen(rule),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
render: (_value, rule): JSX.Element => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
suffix={<ChevronDown size={14} />}
|
||||
testId={`edit-rule-${rule.id}`}
|
||||
onClick={(): void => onEdit(rule)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table<PricingRule>
|
||||
className="model-costs-table"
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={rules}
|
||||
loading={isLoading}
|
||||
pagination={false}
|
||||
rowClassName={(row): string =>
|
||||
row.id === selectedRuleId ? 'model-costs-table__row--selected' : ''
|
||||
}
|
||||
data-testid="model-costs-table"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostsTable;
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type LlmpricingruletypesLLMPricingRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityModelPricing from '../LLMObservabilityModelPricing';
|
||||
|
||||
const ENDPOINT = '*/api/v1/llm_pricing_rules';
|
||||
|
||||
const mockRules: LlmpricingruletypesLLMPricingRuleDTO[] = [
|
||||
{
|
||||
id: 'rule-gpt4o',
|
||||
orgId: 'org-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
modelPattern: ['gpt-4o'],
|
||||
isOverride: false,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 15, output: 60 },
|
||||
},
|
||||
{
|
||||
id: 'rule-llama',
|
||||
orgId: 'org-1',
|
||||
modelName: 'llama-3.1-70b',
|
||||
provider: 'Self-hosted',
|
||||
modelPattern: ['llama-3.1'],
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 0, output: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
describe('LLMObservabilityModelPricing', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
items: mockRules,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
total: mockRules.length,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the page header and both rules', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
expect(screen.getByText('Configuration')).toBeInTheDocument();
|
||||
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
|
||||
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters rules by the search input', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'llama' },
|
||||
});
|
||||
|
||||
expect(screen.queryByText('gpt-4o')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the drawer in Add mode when the Add button is clicked', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
fireEvent.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
const input = (await screen.findByTestId(
|
||||
'drawer-model-id-input',
|
||||
)) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('opens the drawer in Edit mode with prefilled values when a row Edit is clicked', async () => {
|
||||
render(<LLMObservabilityModelPricing />);
|
||||
|
||||
await screen.findByText('gpt-4o');
|
||||
fireEvent.click(screen.getByTestId('edit-rule-rule-gpt4o'));
|
||||
|
||||
const input = (await screen.findByTestId(
|
||||
'drawer-model-id-input',
|
||||
)) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe('gpt-4o');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import { EMPTY_DRAFT, type DrawerDraft } from '../drawerUtils';
|
||||
import ModelCostDrawer from '../ModelCostDrawer';
|
||||
|
||||
interface HarnessProps {
|
||||
initialDraft?: DrawerDraft;
|
||||
mode?: 'add' | 'edit';
|
||||
onSave?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function Harness({
|
||||
initialDraft = { ...EMPTY_DRAFT, modelName: 'gpt-4o' },
|
||||
mode = 'add',
|
||||
onSave = jest.fn(),
|
||||
onDelete = jest.fn(),
|
||||
}: HarnessProps): JSX.Element {
|
||||
const [draft, setDraft] = useState<DrawerDraft>(initialDraft);
|
||||
return (
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode={mode}
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ModelCostDrawer', () => {
|
||||
it('adds a pattern chip when the user types and presses Enter', () => {
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('drawer-pattern-input'), {
|
||||
target: { value: 'gpt-4o-mini' },
|
||||
});
|
||||
fireEvent.keyDown(screen.getByTestId('drawer-pattern-input'), {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
});
|
||||
|
||||
expect(screen.getByText('gpt-4o-mini*')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables pricing fields when isOverride is false', () => {
|
||||
render(
|
||||
<Harness
|
||||
mode="edit"
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
id: 'rule-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled();
|
||||
expect(screen.getByTestId('drawer-output-cost')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables pricing fields when isOverride is true', () => {
|
||||
render(
|
||||
<Harness
|
||||
mode="edit"
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
id: 'rule-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-input-cost')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('drawer-output-cost')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the Provider select in Edit mode but allows it in Add mode', () => {
|
||||
const { unmount } = render(<Harness mode="add" />);
|
||||
|
||||
expect(screen.getByTestId('drawer-provider-select')).not.toHaveAttribute(
|
||||
'data-disabled',
|
||||
);
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<Harness
|
||||
mode="edit"
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
id: 'rule-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-provider-select')).toHaveAttribute(
|
||||
'data-disabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a reset confirmation when switching from Override to Auto', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<Harness
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-source-auto'));
|
||||
|
||||
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-reset-keep-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Delete action in Add mode', () => {
|
||||
render(<Harness mode="add" />);
|
||||
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Delete action in Edit mode', () => {
|
||||
render(
|
||||
<Harness
|
||||
mode="edit"
|
||||
initialDraft={{
|
||||
...EMPTY_DRAFT,
|
||||
id: 'rule-1',
|
||||
modelName: 'gpt-4o',
|
||||
isOverride: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('drawer-delete-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSave when the Save button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(<Harness onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
buildPricingPayload,
|
||||
buildRulePayload,
|
||||
computeCostPreview,
|
||||
draftFromRule,
|
||||
EMPTY_DRAFT,
|
||||
matchesAnyPattern,
|
||||
validateDraft,
|
||||
type DrawerDraft,
|
||||
} from '../drawerUtils';
|
||||
import type { PricingRule } from '../utils';
|
||||
|
||||
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
|
||||
id: 'rule-1',
|
||||
orgId: 'org-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
modelPattern: ['gpt-4o'],
|
||||
isOverride: false,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 15, output: 60 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('drawerUtils', () => {
|
||||
describe('draftFromRule', () => {
|
||||
it('maps a rule to a draft with cache values when present', () => {
|
||||
const rule = makeRule({
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 15,
|
||||
cache: {
|
||||
mode: CacheModeDTO.additive,
|
||||
read: 0.3,
|
||||
write: 3.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
const draft = draftFromRule(rule);
|
||||
expect(draft.modelName).toBe('gpt-4o');
|
||||
expect(draft.pricing.input).toBe(3);
|
||||
expect(draft.pricing.cacheRead).toBe(0.3);
|
||||
expect(draft.pricing.cacheWrite).toBe(3.75);
|
||||
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.additive);
|
||||
});
|
||||
|
||||
it('falls back to defaults when cache is missing', () => {
|
||||
const draft = draftFromRule(makeRule());
|
||||
expect(draft.pricing.cacheRead).toBeNull();
|
||||
expect(draft.pricing.cacheWrite).toBeNull();
|
||||
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPricingPayload', () => {
|
||||
it('omits the cache block when no cache values are set', () => {
|
||||
const payload = buildPricingPayload(EMPTY_DRAFT);
|
||||
expect(payload.cache).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes only the cache values that are > 0', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
pricing: {
|
||||
...EMPTY_DRAFT.pricing,
|
||||
cacheRead: 1.5,
|
||||
cacheWrite: 0,
|
||||
cacheMode: CacheModeDTO.subtract,
|
||||
},
|
||||
};
|
||||
const payload = buildPricingPayload(draft);
|
||||
expect(payload.cache?.read).toBe(1.5);
|
||||
expect(payload.cache?.write).toBeUndefined();
|
||||
expect(payload.cache?.mode).toBe(CacheModeDTO.subtract);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRulePayload', () => {
|
||||
it('uses the modelName as a default pattern when no patterns are supplied', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
patterns: [],
|
||||
provider: 'OpenAI',
|
||||
};
|
||||
const payload = buildRulePayload(draft);
|
||||
expect(payload.modelPattern).toStrictEqual(['gpt-4o']);
|
||||
expect(payload.unit).toBe(UnitDTO.per_million_tokens);
|
||||
expect(payload.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('omits id and sourceId for an Add draft', () => {
|
||||
const payload = buildRulePayload(EMPTY_DRAFT);
|
||||
expect(payload.id).toBeUndefined();
|
||||
expect(payload.sourceId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDraft', () => {
|
||||
it('requires a model name in Add mode', () => {
|
||||
const result = validateDraft(EMPTY_DRAFT, 'add');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/billing model id/i);
|
||||
});
|
||||
|
||||
it('rejects negative pricing', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: -1 },
|
||||
};
|
||||
expect(validateDraft(draft, 'add').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a valid Add draft', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
modelName: 'gpt-4o',
|
||||
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 2 },
|
||||
};
|
||||
expect(validateDraft(draft, 'add').ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesAnyPattern', () => {
|
||||
it('returns the matching prefix pattern, case-insensitive', () => {
|
||||
expect(matchesAnyPattern('GPT-4o-2024', ['gpt-4o'])).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('returns null when nothing matches', () => {
|
||||
expect(matchesAnyPattern('claude', ['gpt-4o'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeCostPreview', () => {
|
||||
it('adds cache buckets when they are set', () => {
|
||||
const draft: DrawerDraft = {
|
||||
...EMPTY_DRAFT,
|
||||
pricing: {
|
||||
...EMPTY_DRAFT.pricing,
|
||||
input: 10,
|
||||
output: 30,
|
||||
cacheRead: 5,
|
||||
},
|
||||
};
|
||||
const preview = computeCostPreview(draft);
|
||||
const labels = preview.breakdown.map((part) => part.label);
|
||||
expect(labels).toContain('2000 input');
|
||||
expect(labels).toContain('500 output');
|
||||
expect(labels).toContain('1000 cache_read');
|
||||
expect(preview.total).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
filterRules,
|
||||
formatPricePerMillion,
|
||||
getCanonicalId,
|
||||
getExtraBuckets,
|
||||
getRelativeLastSeen,
|
||||
getSourceLabel,
|
||||
type PricingRule,
|
||||
} from '../utils';
|
||||
|
||||
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
|
||||
id: 'rule-1',
|
||||
orgId: 'org-1',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
modelPattern: ['gpt-4o'],
|
||||
isOverride: false,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 15, output: 60 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('utils', () => {
|
||||
describe('formatPricePerMillion', () => {
|
||||
it('formats numbers with 2 decimals and dollar prefix', () => {
|
||||
expect(formatPricePerMillion(15)).toBe('$15.00');
|
||||
expect(formatPricePerMillion(0.15)).toBe('$0.15');
|
||||
});
|
||||
|
||||
it('returns em-dash for nullish or NaN', () => {
|
||||
expect(formatPricePerMillion(undefined)).toBe('—');
|
||||
expect(formatPricePerMillion(Number.NaN)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtraBuckets', () => {
|
||||
it('returns an empty array when there is no cache pricing', () => {
|
||||
expect(getExtraBuckets(makeRule())).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns only buckets with values > 0', () => {
|
||||
const rule = makeRule({
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 15,
|
||||
cache: {
|
||||
mode: CacheModeDTO.additive,
|
||||
read: 0.3,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const buckets = getExtraBuckets(rule);
|
||||
expect(buckets).toStrictEqual([{ key: 'cache_read', pricePerMillion: 0.3 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourceLabel', () => {
|
||||
it('returns "Auto" for non-override and "User override" otherwise', () => {
|
||||
expect(getSourceLabel(makeRule({ isOverride: false }))).toBe('Auto');
|
||||
expect(getSourceLabel(makeRule({ isOverride: true }))).toBe('User override');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCanonicalId', () => {
|
||||
it('lowercases the provider and joins with the model name', () => {
|
||||
expect(getCanonicalId(makeRule({ provider: 'OpenAI' }))).toBe(
|
||||
'openai:gpt-4o',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRelativeLastSeen', () => {
|
||||
it('returns em-dash when no timestamp is present', () => {
|
||||
expect(getRelativeLastSeen(makeRule())).toBe('—');
|
||||
});
|
||||
|
||||
it('formats minutes-old timestamps', () => {
|
||||
const recent = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
expect(getRelativeLastSeen(makeRule({ updatedAt: recent }))).toMatch(
|
||||
/min ago/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRules', () => {
|
||||
const auto = makeRule({ id: 'r1', modelName: 'gpt-4o', isOverride: false });
|
||||
const override = makeRule({
|
||||
id: 'r2',
|
||||
modelName: 'llama-3',
|
||||
provider: 'Self-hosted',
|
||||
modelPattern: ['llama-3'],
|
||||
isOverride: true,
|
||||
});
|
||||
|
||||
it('returns everything when no filters are applied', () => {
|
||||
expect(filterRules([auto, override], '', 'all')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('narrows by source = override', () => {
|
||||
expect(filterRules([auto, override], '', 'override')).toStrictEqual([
|
||||
override,
|
||||
]);
|
||||
});
|
||||
|
||||
it('narrows by free-text search across model and provider', () => {
|
||||
expect(filterRules([auto, override], 'self', 'all')).toStrictEqual([
|
||||
override,
|
||||
]);
|
||||
expect(filterRules([auto, override], 'gpt-4', 'all')).toStrictEqual([auto]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type LlmpricingruletypesLLMPricingCacheCostsDTO,
|
||||
type LlmpricingruletypesLLMRulePricingDTO,
|
||||
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PricingRule } from './utils';
|
||||
|
||||
export const PROVIDER_OPTIONS = [
|
||||
{ value: 'OpenAI', label: 'OpenAI' },
|
||||
{ value: 'Anthropic', label: 'Anthropic' },
|
||||
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
|
||||
{ value: 'Google', label: 'Google' },
|
||||
{ value: 'Self-hosted', label: 'Self-hosted' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
|
||||
export const CACHE_MODE_OPTIONS = [
|
||||
{
|
||||
value: CacheModeDTO.subtract,
|
||||
label: 'Subtract (OpenAI style)',
|
||||
},
|
||||
{
|
||||
value: CacheModeDTO.additive,
|
||||
label: 'Additive (Anthropic style)',
|
||||
},
|
||||
{
|
||||
value: CacheModeDTO.unknown,
|
||||
label: 'Unknown',
|
||||
},
|
||||
];
|
||||
|
||||
export type DrawerMode = 'add' | 'edit';
|
||||
|
||||
export interface DrawerDraft {
|
||||
id: string | null;
|
||||
sourceId: string | null;
|
||||
modelName: string;
|
||||
provider: string;
|
||||
patterns: string[];
|
||||
isOverride: boolean;
|
||||
pricing: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheMode: CacheModeDTO;
|
||||
cacheRead: number | null;
|
||||
cacheWrite: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const EMPTY_DRAFT: DrawerDraft = {
|
||||
id: null,
|
||||
sourceId: null,
|
||||
modelName: '',
|
||||
provider: 'OpenAI',
|
||||
patterns: [],
|
||||
isOverride: true,
|
||||
pricing: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheMode: CacheModeDTO.unknown,
|
||||
cacheRead: null,
|
||||
cacheWrite: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
|
||||
id: rule.id,
|
||||
sourceId: rule.sourceId ?? null,
|
||||
modelName: rule.modelName,
|
||||
provider: rule.provider || 'OpenAI',
|
||||
patterns: rule.modelPattern || [],
|
||||
isOverride: !!rule.isOverride,
|
||||
pricing: {
|
||||
input: rule.pricing?.input ?? 0,
|
||||
output: rule.pricing?.output ?? 0,
|
||||
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
|
||||
cacheRead: rule.pricing?.cache?.read ?? null,
|
||||
cacheWrite: rule.pricing?.cache?.write ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const hasCacheValue = (value: number | null): boolean =>
|
||||
typeof value === 'number' && value > 0;
|
||||
|
||||
export const buildPricingPayload = (
|
||||
draft: DrawerDraft,
|
||||
): LlmpricingruletypesLLMRulePricingDTO => {
|
||||
const pricing: LlmpricingruletypesLLMRulePricingDTO = {
|
||||
input: draft.pricing.input,
|
||||
output: draft.pricing.output,
|
||||
};
|
||||
if (
|
||||
hasCacheValue(draft.pricing.cacheRead) ||
|
||||
hasCacheValue(draft.pricing.cacheWrite)
|
||||
) {
|
||||
const cache: LlmpricingruletypesLLMPricingCacheCostsDTO = {
|
||||
mode: draft.pricing.cacheMode,
|
||||
};
|
||||
if (hasCacheValue(draft.pricing.cacheRead)) {
|
||||
cache.read = draft.pricing.cacheRead as number;
|
||||
}
|
||||
if (hasCacheValue(draft.pricing.cacheWrite)) {
|
||||
cache.write = draft.pricing.cacheWrite as number;
|
||||
}
|
||||
pricing.cache = cache;
|
||||
}
|
||||
return pricing;
|
||||
};
|
||||
|
||||
export const buildRulePayload = (
|
||||
draft: DrawerDraft,
|
||||
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
|
||||
id: draft.id || undefined,
|
||||
sourceId: draft.sourceId || undefined,
|
||||
modelName: draft.modelName.trim(),
|
||||
provider: draft.provider.trim(),
|
||||
modelPattern:
|
||||
draft.patterns.length > 0 ? draft.patterns : [draft.modelName.trim()],
|
||||
isOverride: draft.isOverride,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: buildPricingPayload(draft),
|
||||
});
|
||||
|
||||
export interface ValidationResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const validateDraft = (
|
||||
draft: DrawerDraft,
|
||||
mode: DrawerMode,
|
||||
): ValidationResult => {
|
||||
if (mode === 'add' && !draft.modelName.trim()) {
|
||||
return { ok: false, message: 'Billing model ID is required.' };
|
||||
}
|
||||
if (!draft.provider.trim()) {
|
||||
return { ok: false, message: 'Provider is required.' };
|
||||
}
|
||||
if (draft.pricing.input < 0 || draft.pricing.output < 0) {
|
||||
return { ok: false, message: 'Pricing values must be non-negative.' };
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
export const matchesAnyPattern = (
|
||||
candidate: string,
|
||||
patterns: string[],
|
||||
): string | null => {
|
||||
const lowered = candidate.toLowerCase();
|
||||
const match = patterns.find((pattern) =>
|
||||
lowered.startsWith(pattern.toLowerCase()),
|
||||
);
|
||||
return match || null;
|
||||
};
|
||||
|
||||
const EXAMPLE_INPUT_TOKENS = 2000;
|
||||
const EXAMPLE_OUTPUT_TOKENS = 500;
|
||||
const EXAMPLE_CACHE_TOKENS = 1000;
|
||||
const PER_MILLION = 1_000_000;
|
||||
|
||||
export interface CostPreviewParts {
|
||||
total: number;
|
||||
breakdown: { label: string; cost: number }[];
|
||||
}
|
||||
|
||||
export const computeCostPreview = (draft: DrawerDraft): CostPreviewParts => {
|
||||
const breakdown: { label: string; cost: number }[] = [];
|
||||
const inputCost = (EXAMPLE_INPUT_TOKENS / PER_MILLION) * draft.pricing.input;
|
||||
const outputCost =
|
||||
(EXAMPLE_OUTPUT_TOKENS / PER_MILLION) * draft.pricing.output;
|
||||
breakdown.push({ label: `${EXAMPLE_INPUT_TOKENS} input`, cost: inputCost });
|
||||
breakdown.push({ label: `${EXAMPLE_OUTPUT_TOKENS} output`, cost: outputCost });
|
||||
let total = inputCost + outputCost;
|
||||
if (hasCacheValue(draft.pricing.cacheRead)) {
|
||||
const cost =
|
||||
(EXAMPLE_CACHE_TOKENS / PER_MILLION) * (draft.pricing.cacheRead as number);
|
||||
breakdown.push({ label: `${EXAMPLE_CACHE_TOKENS} cache_read`, cost });
|
||||
total += cost;
|
||||
}
|
||||
if (hasCacheValue(draft.pricing.cacheWrite)) {
|
||||
const cost =
|
||||
(EXAMPLE_CACHE_TOKENS / PER_MILLION) * (draft.pricing.cacheWrite as number);
|
||||
breakdown.push({ label: `${EXAMPLE_CACHE_TOKENS} cache_write`, cost });
|
||||
total += cost;
|
||||
}
|
||||
return { total, breakdown };
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getListLLMPricingRulesQueryKey,
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
useDeleteLLMPricingRule,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import {
|
||||
buildRulePayload,
|
||||
draftFromRule,
|
||||
EMPTY_DRAFT,
|
||||
type DrawerDraft,
|
||||
type DrawerMode,
|
||||
} from './drawerUtils';
|
||||
import type { PricingRule } from './utils';
|
||||
|
||||
interface UseModelCostDrawerResult {
|
||||
isOpen: boolean;
|
||||
mode: DrawerMode;
|
||||
draft: DrawerDraft;
|
||||
setDraft: (next: DrawerDraft) => void;
|
||||
openForAdd: (prefillModelName?: string) => void;
|
||||
openForEdit: (rule: PricingRule) => void;
|
||||
close: () => void;
|
||||
save: () => Promise<void>;
|
||||
deleteRule: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
selectedRuleId: string | null;
|
||||
}
|
||||
|
||||
export function useModelCostDrawer(): UseModelCostDrawerResult {
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<DrawerMode>('add');
|
||||
const [draft, setDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
|
||||
useCreateOrUpdateLLMPricingRules();
|
||||
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
|
||||
useDeleteLLMPricingRule();
|
||||
|
||||
const invalidateList = useCallback(async (): Promise<void> => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
const openForAdd = useCallback((prefillModelName?: string): void => {
|
||||
setMode('add');
|
||||
setDraft({
|
||||
...EMPTY_DRAFT,
|
||||
modelName: prefillModelName || '',
|
||||
patterns: prefillModelName ? [prefillModelName] : [],
|
||||
});
|
||||
setSelectedRuleId(null);
|
||||
setSaveError(null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((rule: PricingRule): void => {
|
||||
setMode('edit');
|
||||
setDraft(draftFromRule(rule));
|
||||
setSelectedRuleId(rule.id);
|
||||
setSaveError(null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (): Promise<void> => {
|
||||
setSaveError(null);
|
||||
try {
|
||||
await createOrUpdate({
|
||||
data: { rules: [buildRulePayload(draft)] },
|
||||
});
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
}
|
||||
}, [createOrUpdate, draft, invalidateList]);
|
||||
|
||||
const deleteRule = useCallback(async (): Promise<void> => {
|
||||
if (!draft.id) {
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
try {
|
||||
await deleteRuleApi({ pathParams: { id: draft.id } });
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Delete failed';
|
||||
setSaveError(message);
|
||||
}
|
||||
}, [deleteRuleApi, draft.id, invalidateList]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
openForAdd,
|
||||
openForEdit,
|
||||
close,
|
||||
save,
|
||||
deleteRule,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
selectedRuleId,
|
||||
};
|
||||
}
|
||||
101
frontend/src/container/LLMObservabilityModelPricing/utils.ts
Normal file
101
frontend/src/container/LLMObservabilityModelPricing/utils.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
|
||||
|
||||
export type SourceFilter = 'all' | 'auto' | 'override';
|
||||
|
||||
export interface ExtraBucket {
|
||||
key: string;
|
||||
pricePerMillion: number;
|
||||
}
|
||||
|
||||
export const formatPricePerMillion = (value: number | undefined): string => {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
|
||||
const cache = rule.pricing?.cache;
|
||||
if (!cache) {
|
||||
return [];
|
||||
}
|
||||
const buckets: ExtraBucket[] = [];
|
||||
if (typeof cache.read === 'number' && cache.read > 0) {
|
||||
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
|
||||
}
|
||||
if (typeof cache.write === 'number' && cache.write > 0) {
|
||||
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
|
||||
}
|
||||
return buckets;
|
||||
};
|
||||
|
||||
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
|
||||
rule.isOverride ? 'User override' : 'Auto';
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 365 * DAY;
|
||||
|
||||
export const getRelativeLastSeen = (rule: PricingRule): string => {
|
||||
const ts = rule.updatedAt || rule.syncedAt || rule.createdAt;
|
||||
if (!ts) {
|
||||
return '—';
|
||||
}
|
||||
const now = Date.now();
|
||||
const target = new Date(ts).getTime();
|
||||
if (Number.isNaN(target)) {
|
||||
return '—';
|
||||
}
|
||||
const seconds = Math.max(0, Math.round((now - target) / 1000));
|
||||
if (seconds < MINUTE) {
|
||||
return 'just now';
|
||||
}
|
||||
if (seconds < HOUR) {
|
||||
return `${Math.floor(seconds / MINUTE)} min ago`;
|
||||
}
|
||||
if (seconds < DAY) {
|
||||
return `${Math.floor(seconds / HOUR)} hr ago`;
|
||||
}
|
||||
if (seconds < MONTH) {
|
||||
return `${Math.floor(seconds / DAY)} days ago`;
|
||||
}
|
||||
if (seconds < YEAR) {
|
||||
return `${Math.floor(seconds / MONTH)} mo ago`;
|
||||
}
|
||||
return `${Math.floor(seconds / YEAR)} yr ago`;
|
||||
};
|
||||
|
||||
const lc = (value: string): string => value.toLowerCase();
|
||||
|
||||
export const filterRules = (
|
||||
rules: PricingRule[],
|
||||
search: string,
|
||||
source: SourceFilter,
|
||||
): PricingRule[] => {
|
||||
const normalized = lc(search.trim());
|
||||
return rules.filter((rule) => {
|
||||
if (source === 'auto' && rule.isOverride) {
|
||||
return false;
|
||||
}
|
||||
if (source === 'override' && !rule.isOverride) {
|
||||
return false;
|
||||
}
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
lc(rule.modelName).includes(normalized) ||
|
||||
lc(rule.provider).includes(normalized) ||
|
||||
(rule.modelPattern || []).some((pattern) => lc(pattern).includes(normalized))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const getCanonicalId = (rule: PricingRule): string => {
|
||||
const provider = rule.provider?.trim() || 'unknown';
|
||||
return `${lc(provider)}:${rule.modelName}`;
|
||||
};
|
||||
@@ -151,11 +151,6 @@ export function PlannedDowntimeForm(
|
||||
|
||||
const saveHandler = useCallback(
|
||||
async (values: PlannedDowntimeFormData) => {
|
||||
const { startTime, timezone } = values;
|
||||
if (!startTime || !timezone) {
|
||||
// unreachable: required fields should always be present on submitting.
|
||||
return;
|
||||
}
|
||||
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
|
||||
alertIds:
|
||||
values.alertRuleScope === 'all'
|
||||
@@ -166,9 +161,9 @@ export function PlannedDowntimeForm(
|
||||
name: values.name,
|
||||
scope: values.scope,
|
||||
schedule: {
|
||||
startTime: startTime.format(),
|
||||
startTime: values.startTime?.format(),
|
||||
endTime: values.endTime?.format(),
|
||||
timezone,
|
||||
timezone: values.timezone!,
|
||||
recurrence: values.recurrence,
|
||||
},
|
||||
};
|
||||
@@ -205,17 +200,25 @@ export function PlannedDowntimeForm(
|
||||
],
|
||||
);
|
||||
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
|
||||
const rec = values.recurrence;
|
||||
const recurrence =
|
||||
rec && rec.repeatType !== recurrenceOptions.doesNotRepeat.value
|
||||
? {
|
||||
duration: `${rec.duration}${durationUnit}`,
|
||||
repeatOn: rec.repeatOn,
|
||||
repeatType: rec.repeatType,
|
||||
}
|
||||
: undefined;
|
||||
const { recurrence } = values;
|
||||
const recurrenceData =
|
||||
!recurrence ||
|
||||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
|
||||
? undefined
|
||||
: {
|
||||
duration: recurrence.duration
|
||||
? `${recurrence.duration}${durationUnit}`
|
||||
: '',
|
||||
startTime: values.startTime!.format(),
|
||||
endTime: values.endTime?.format(),
|
||||
repeatOn: recurrence.repeatOn,
|
||||
repeatType: recurrence.repeatType,
|
||||
};
|
||||
|
||||
await saveHandler({ ...values, recurrence });
|
||||
await saveHandler({
|
||||
...values,
|
||||
recurrence: recurrenceData,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormData = (data: Partial<PlannedDowntimeFormData>): void => {
|
||||
@@ -272,6 +275,9 @@ export function PlannedDowntimeForm(
|
||||
|
||||
const formattedInitialValues = useMemo((): PlannedDowntimeFormData => {
|
||||
const { schedule } = initialValues;
|
||||
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
|
||||
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
|
||||
|
||||
const initialAlertIds = initialValues.alertIds || [];
|
||||
|
||||
return {
|
||||
@@ -279,12 +285,8 @@ export function PlannedDowntimeForm(
|
||||
alertRuleScope:
|
||||
isEditMode && initialAlertIds.length === 0 ? 'all' : 'specific',
|
||||
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
|
||||
startTime: schedule?.startTime
|
||||
? dayjs(schedule.startTime).tz(schedule.timezone)
|
||||
: null,
|
||||
endTime: schedule?.endTime
|
||||
? dayjs(schedule.endTime).tz(schedule.timezone)
|
||||
: null,
|
||||
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
|
||||
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
|
||||
recurrence: {
|
||||
...schedule?.recurrence,
|
||||
repeatType: !isScheduleRecurring(schedule)
|
||||
@@ -295,7 +297,7 @@ export function PlannedDowntimeForm(
|
||||
timezone: schedule?.timezone as string,
|
||||
scope: initialValues.scope || '',
|
||||
};
|
||||
}, [initialValues, isEditMode, alertOptions]);
|
||||
}, [initialValues, alertOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTags(formattedInitialValues.alertRules);
|
||||
@@ -339,7 +341,7 @@ export function PlannedDowntimeForm(
|
||||
const formattedEndTime = endTime.format(TIME_FORMAT);
|
||||
const formattedEndDate = endTime.format(DATE_FORMAT);
|
||||
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
|
||||
}, [formData]);
|
||||
}, [formData, recurrenceType]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -142,6 +142,7 @@ export function CollapseListContent({
|
||||
updated_by_name?: string;
|
||||
alertOptions?: DefaultOptionType[];
|
||||
}): JSX.Element {
|
||||
const repeats = schedule?.recurrence;
|
||||
const renderItems = (title: string, value: ReactNode): JSX.Element => (
|
||||
<div className="render-item-collapse-list">
|
||||
<Typography>{title}</Typography>
|
||||
@@ -192,7 +193,10 @@ export function CollapseListContent({
|
||||
'Timezone',
|
||||
<Typography>{schedule?.timezone || '-'}</Typography>,
|
||||
)}
|
||||
{renderItems('Repeats', <Typography>{recurrenceInfo(schedule)}</Typography>)}
|
||||
{renderItems(
|
||||
'Repeats',
|
||||
<Typography>{recurrenceInfo(repeats, schedule?.timezone)}</Typography>,
|
||||
)}
|
||||
{renderItems(
|
||||
'Alerts silenced',
|
||||
alertOptions?.length ? (
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
DeleteDowntimeScheduleByIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
AlertmanagertypesPlannedMaintenanceDTO,
|
||||
AlertmanagertypesScheduleDTO,
|
||||
AlertmanagertypesRecurrenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { AxiosError } from 'axios';
|
||||
@@ -66,17 +66,14 @@ export const getAlertOptionsFromIds = (
|
||||
);
|
||||
|
||||
export const recurrenceInfo = (
|
||||
schedule?: AlertmanagertypesScheduleDTO | null,
|
||||
recurrence?: AlertmanagertypesRecurrenceDTO | null,
|
||||
timezone?: string,
|
||||
): string => {
|
||||
if (!schedule) {
|
||||
return 'No';
|
||||
}
|
||||
const { startTime, endTime, timezone, recurrence } = schedule;
|
||||
if (!recurrence) {
|
||||
return 'No';
|
||||
}
|
||||
|
||||
const { duration, repeatOn, repeatType } = recurrence;
|
||||
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
|
||||
|
||||
const formattedStartTime = startTime
|
||||
? formatDateTime(startTime, timezone)
|
||||
@@ -98,7 +95,7 @@ export const defaultInitialValues: Partial<AlertmanagertypesPlannedMaintenanceDT
|
||||
timezone: '',
|
||||
endTime: undefined,
|
||||
recurrence: undefined,
|
||||
startTime: '',
|
||||
startTime: undefined,
|
||||
},
|
||||
alertIds: [],
|
||||
createdAt: undefined,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -142,15 +142,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.reset-password-back-action {
|
||||
margin-top: var(--spacing-12);
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { ArrowLeft, CircleAlert } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './ResetPassword.styles.scss';
|
||||
@@ -62,16 +59,6 @@ function TokenError({ error }: TokenErrorProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{error && <AuthError error={error} />}
|
||||
<div className="reset-password-back-action">
|
||||
<Button
|
||||
variant="solid"
|
||||
data-testid="back-to-login"
|
||||
prefix={<ArrowLeft size={12} />}
|
||||
onClick={(): void => history.push(ROUTES.LOGIN)}
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
|
||||
@@ -119,10 +119,6 @@
|
||||
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
background: var(--l3-background);
|
||||
|
||||
&.version-container-standalone {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
@@ -1135,9 +1131,17 @@
|
||||
|
||||
.settings-dropdown,
|
||||
.help-support-dropdown {
|
||||
.user-settings-dropdown-logout-section {
|
||||
color: var(--danger-background);
|
||||
pointer-events: auto;
|
||||
.ant-dropdown-menu-item {
|
||||
min-height: 32px;
|
||||
|
||||
.ant-dropdown-menu-title-content {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
.user-settings-dropdown-logout-section {
|
||||
color: var(--danger-background);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1010,7 +1010,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
<img src={signozBrandLogoUrl} alt="SigNoz" />
|
||||
</div>
|
||||
|
||||
{(licenseTag || currentVersion) && (
|
||||
{licenseTag && (
|
||||
<div
|
||||
className={cx(
|
||||
'brand-title-section',
|
||||
@@ -1021,7 +1021,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
'version-update-notification',
|
||||
)}
|
||||
>
|
||||
{licenseTag && <span className="license-type"> {licenseTag} </span>}
|
||||
<span className="license-type"> {licenseTag} </span>
|
||||
|
||||
{currentVersion && (
|
||||
<Tooltip
|
||||
@@ -1043,12 +1043,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
'version-container',
|
||||
!licenseTag && 'version-container-standalone',
|
||||
)}
|
||||
>
|
||||
<div className="version-container">
|
||||
<span
|
||||
className={cx('version', changelog && 'version-clickable')}
|
||||
onClick={onClickVersionHandler}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Building2,
|
||||
ChartArea,
|
||||
Cloudy,
|
||||
Coins,
|
||||
DraftingCompass,
|
||||
FileKey2,
|
||||
Github,
|
||||
@@ -365,6 +366,13 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
isEnabled: false,
|
||||
itemKey: 'mcp-server',
|
||||
},
|
||||
{
|
||||
key: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
label: 'Model Pricing',
|
||||
icon: <Coins size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'llm-observability-model-pricing',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ export const routesToSkip = [
|
||||
ROUTES.METER,
|
||||
ROUTES.METER_EXPLORER_VIEWS,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { getFlamegraph } from 'api/generated/services/tracedetail';
|
||||
import {
|
||||
SpantypesGettableFlamegraphTraceDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export interface GetTraceFlamegraphV3Props {
|
||||
traceId: string;
|
||||
selectedSpanId?: string;
|
||||
selectFields?: TelemetryFieldKey[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const useGetTraceFlamegraphV3 = (
|
||||
props: GetTraceFlamegraphV3Props,
|
||||
): UseQueryResult<SpantypesGettableFlamegraphTraceDTO, unknown> =>
|
||||
useQuery({
|
||||
queryFn: () =>
|
||||
getFlamegraph(
|
||||
{ traceID: props.traceId },
|
||||
{
|
||||
selectedSpanId: props.selectedSpanId,
|
||||
// v5 TelemetryFieldKey and the generated DTO are runtime-identical; only
|
||||
// the literal-union vs enum nominal types differ
|
||||
selectFields: props.selectFields as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
},
|
||||
).then((res) => res.data),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V3_FLAMEGRAPH,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.selectFields,
|
||||
],
|
||||
enabled: props.enabled,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
export default useGetTraceFlamegraphV3;
|
||||
@@ -22,13 +22,11 @@ interface CacheEntry {
|
||||
const CACHE_SIZE_LIMIT = 1000;
|
||||
const CACHE_CLEANUP_PERCENTAGE = 0.5; // Remove 50% when limit is reached
|
||||
|
||||
export type FormatTimezoneAdjustedTimestamp = (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string;
|
||||
|
||||
function useTimezoneFormatter({ userTimezone }: { userTimezone: Timezone }): {
|
||||
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp;
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string;
|
||||
} {
|
||||
// Initialize cache using useMemo to persist between renders
|
||||
const cache = useMemo(() => new Map<string, CacheEntry>(), []);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainer from 'container/DashboardContainer';
|
||||
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { ErrorType } from 'types/common';
|
||||
|
||||
function DashboardPage(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const [onModal, Content] = Modal.useModal();
|
||||
|
||||
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
|
||||
dashboardId,
|
||||
{ confirm: onModal.confirm },
|
||||
);
|
||||
|
||||
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = dashboardTitle || document.title;
|
||||
}, [dashboardTitle]);
|
||||
|
||||
const errorMessage = isError
|
||||
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
|
||||
: 'Something went wrong';
|
||||
|
||||
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
if (isError && errorMessage) {
|
||||
return <Typography>{errorMessage}</Typography>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Content}
|
||||
<DashboardContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -1,15 +1,53 @@
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import DashboardPageV2 from 'pages/DashboardPageV2';
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainer from 'container/DashboardContainer';
|
||||
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { ErrorType } from 'types/common';
|
||||
|
||||
import DashboardPage from './DashboardPage';
|
||||
function DashboardPage(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
// Serves the V2 dashboard detail page when the `use_dashboard_v2` flag is active;
|
||||
// otherwise the existing V1 page. Lets V2 dark-ship behind the flag without
|
||||
// changing route definitions.
|
||||
function DashboardPageEntry(): JSX.Element {
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
const [onModal, Content] = Modal.useModal();
|
||||
|
||||
return isDashboardV2 ? <DashboardPageV2 /> : <DashboardPage />;
|
||||
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
|
||||
dashboardId,
|
||||
{ confirm: onModal.confirm },
|
||||
);
|
||||
|
||||
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = dashboardTitle || document.title;
|
||||
}, [dashboardTitle]);
|
||||
|
||||
const errorMessage = isError
|
||||
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
|
||||
: 'Something went wrong';
|
||||
|
||||
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
if (isError && errorMessage) {
|
||||
return <Typography>{errorMessage}</Typography>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Content}
|
||||
<DashboardContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageEntry;
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -28,40 +28,43 @@ import { USER_ROLES } from 'types/roles';
|
||||
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import SettingsDrawer from '../SettingsDrawer';
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import styles from '../DashboardDescription.module.scss';
|
||||
|
||||
interface DashboardActionsProps {
|
||||
title: string;
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
isDashboardLocked: boolean;
|
||||
editDashboard: boolean;
|
||||
isAuthor: boolean;
|
||||
addPanelPermission: boolean;
|
||||
onAddPanel: () => void;
|
||||
onLockToggle: () => void;
|
||||
onOpenRename: () => void;
|
||||
}
|
||||
|
||||
function DashboardActions({
|
||||
title,
|
||||
dashboard,
|
||||
handle,
|
||||
isDashboardLocked,
|
||||
editDashboard,
|
||||
isAuthor,
|
||||
addPanelPermission,
|
||||
onAddPanel,
|
||||
onLockToggle,
|
||||
onOpenRename,
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const { user } = useAppContext();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
|
||||
const id = dashboard.id ?? '';
|
||||
const title = dashboard.spec?.display?.name ?? '';
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
|
||||
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
|
||||
const deleteDashboardMutation = useDeleteDashboard(id);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
@@ -100,7 +103,7 @@ function DashboardActions({
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const editGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
if (!isDashboardLocked && editDashboard) {
|
||||
editGroup.push({
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
@@ -156,6 +159,7 @@ function DashboardActions({
|
||||
);
|
||||
}, [
|
||||
isDashboardLocked,
|
||||
editDashboard,
|
||||
isAuthor,
|
||||
user.role,
|
||||
dashboard.createdBy,
|
||||
@@ -165,60 +169,58 @@ function DashboardActions({
|
||||
exportJSON,
|
||||
setCopy,
|
||||
dashboardDataJSON,
|
||||
canEdit,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<div className={styles.rightSection}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<div className={styles.dashboardActionsSecondary}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Ellipsis size={14} />}
|
||||
className={styles.icons}
|
||||
testId="options"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Ellipsis size="md" />}
|
||||
testId="options"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && addPanelPermission && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
title={`Delete dashboard"?`}
|
||||
description={`Are you sure you want to delete this dashboard - "${title}"? This action cannot be undone.`}
|
||||
title={`Delete dashboard "${title}"?`}
|
||||
description="This action cannot be undone."
|
||||
isLoading={deleteDashboardMutation.isLoading}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onClose={(): void => setIsDeleteOpen(false)}
|
||||
@@ -0,0 +1,210 @@
|
||||
.dashboardDescriptionContainer {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: unset;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.dashboardDetails {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 16px 16px 0px 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
.leftSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 45%;
|
||||
height: 40px;
|
||||
|
||||
.dashboardImg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 150% */
|
||||
letter-spacing: -0.08px;
|
||||
max-width: 80%;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clickableTitle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.titleEdit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.titleEditActionButton {
|
||||
--button-height: auto;
|
||||
--button-padding: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titleSaveActionButton {
|
||||
--button-border-color: var(--text-forest-700);
|
||||
--button-outlined-foreground: var(--text-forest-700);
|
||||
}
|
||||
|
||||
.publicDashboardIcon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.rightSection {
|
||||
display: flex;
|
||||
width: 55%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 40px;
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 32px;
|
||||
height: 34px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 10px; /* 83.333% */
|
||||
letter-spacing: 0.12px;
|
||||
}
|
||||
|
||||
.icons:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardTags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 16px 16px 0px 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
text-align: center;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardDescriptionSection {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 22px; /* 157.143% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 20px 16px 0px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardSettings {
|
||||
width: 191px;
|
||||
height: 302px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(.ant-popover-inner) {
|
||||
padding: 0px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
) !important;
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.menuContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: unset;
|
||||
padding: 8px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.section1,
|
||||
.section2 {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.deleteDashboard button {
|
||||
color: var(--bg-cherry-400) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deleteModal :global(.ant-modal-confirm-body) {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
import styles from '../DashboardDescription.module.scss';
|
||||
|
||||
interface DashboardMetaProps {
|
||||
tags: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
function DashboardMeta({ tags, description }: DashboardMetaProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{tags.length > 0 && (
|
||||
<div className={styles.dashboardTags}>
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} className={styles.tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className={styles.dashboardDescriptionSection}>
|
||||
{description}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardMeta;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from '../DashboardDescription.module.scss';
|
||||
|
||||
interface DashboardTitleProps {
|
||||
title: string;
|
||||
image: string;
|
||||
isPublicDashboard: boolean;
|
||||
isDashboardLocked: boolean;
|
||||
isEditable: boolean;
|
||||
isEditing: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function DashboardTitle({
|
||||
title,
|
||||
image,
|
||||
isPublicDashboard,
|
||||
isDashboardLocked,
|
||||
isEditable,
|
||||
isEditing,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onStartEdit,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: DashboardTitleProps): JSX.Element {
|
||||
const canEdit = isEditable && !isDashboardLocked;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onCommit();
|
||||
} else if (event.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.leftSection}>
|
||||
<img src={image} alt="dashboard-img" className={styles.dashboardImg} />
|
||||
{isEditing ? (
|
||||
<div className={styles.titleEdit}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={120}
|
||||
className={styles.titleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
size="icon"
|
||||
className={cx(styles.titleEditActionButton, styles.titleSaveActionButton)}
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
className={styles.titleEditActionButton}
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title.length > 30 ? title : ''}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.clickableTitle]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} className={styles.publicDashboardIcon} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardTitle;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { Card } from 'antd';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
@@ -12,31 +13,34 @@ import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
|
||||
import DashboardMeta from './DashboardMeta/DashboardMeta';
|
||||
import DashboardTitle from './DashboardTitle/DashboardTitle';
|
||||
import { useEditableTitle } from './DashboardTitle/useEditableTitle';
|
||||
|
||||
import styles from './DashboardPageToolbar.module.scss';
|
||||
import styles from './DashboardDescription.module.scss';
|
||||
|
||||
interface DashboardPageToolbarProps {
|
||||
interface DashboardDescriptionProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const { dashboard, handle, refetch } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
const isDashboardLocked = !!dashboard.locked;
|
||||
|
||||
const title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
const title = dashboard.spec?.display?.name ?? '';
|
||||
const description = dashboard.spec?.display?.description ?? '';
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
@@ -47,6 +51,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
@@ -54,6 +59,9 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
const addPanelPermission = !isDashboardLocked;
|
||||
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
|
||||
const isPublicDashboard = false;
|
||||
|
||||
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
|
||||
if (!id) {
|
||||
@@ -102,7 +110,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
onSave: onNameSave,
|
||||
});
|
||||
|
||||
const onAddPanel = useCallback((): void => {
|
||||
const onEmptyWidgetHandler = useCallback((): void => {
|
||||
void logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
@@ -110,15 +118,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
}, [id, setIsPanelTypeSelectionModalOpen]);
|
||||
|
||||
return (
|
||||
<section className={styles.dashboardPageToolbarContainer}>
|
||||
<div className={styles.dashboardInfoWithActions}>
|
||||
<DashboardInfo
|
||||
<Card className={styles.dashboardDescriptionContainer}>
|
||||
<DashboardHeader title={title} image={image} />
|
||||
<section className={styles.dashboardDetails}>
|
||||
<DashboardTitle
|
||||
title={title}
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={false}
|
||||
isPublicDashboard={isPublicDashboard}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isEditable={editDashboard}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
@@ -127,18 +135,20 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
onCancel={cancel}
|
||||
/>
|
||||
<DashboardActions
|
||||
title={title}
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
editDashboard={editDashboard}
|
||||
isAuthor={isAuthor}
|
||||
onAddPanel={onAddPanel}
|
||||
addPanelPermission={addPanelPermission}
|
||||
onAddPanel={onEmptyWidgetHandler}
|
||||
onLockToggle={handleLockDashboardToggle}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<DashboardMeta tags={tags} description={description} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageToolbar;
|
||||
export default DashboardDescription;
|
||||
@@ -1,11 +0,0 @@
|
||||
.dashboardActionsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboardActionsSecondary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
.dashboardInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 40%;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardTitleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboardImage {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboardTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: fit-content;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboardTitleHover {
|
||||
cursor: text !important;
|
||||
}
|
||||
|
||||
.dashboardTitleEditor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboardTitleInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboardTitleActionButton {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboardTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface DashboardInfoProps {
|
||||
title: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
description: string;
|
||||
isPublicDashboard: boolean;
|
||||
isDashboardLocked: boolean;
|
||||
isEditing: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function DashboardInfo({
|
||||
title,
|
||||
image,
|
||||
tags,
|
||||
description,
|
||||
isPublicDashboard,
|
||||
isDashboardLocked,
|
||||
isEditing,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onStartEdit,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: DashboardInfoProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
|
||||
const hasTags = tags.length > 0;
|
||||
const hasDescription = !isEmpty(description);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onCommit();
|
||||
} else if (event.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<div className={styles.dashboardTitleContainer}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={120}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasTags && (
|
||||
<div className={styles.dashboardTags}>
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} color="warning" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDescription && (
|
||||
<Typography.Text color="muted">{description}</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardInfo;
|
||||
@@ -1,20 +0,0 @@
|
||||
.dashboardPageToolbarContainer {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
color: var(--l2-foreground);
|
||||
background-color: var(--l1-background);
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 2px 0px var(--l2-border);
|
||||
}
|
||||
|
||||
.dashboardPageToolbarSubContainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboardInfoWithActions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
.tabsContent {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -14,10 +9,3 @@
|
||||
line-height: 1;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
// shared "settings card" wrapper, used by the dashboard-info form and cross-panel sync
|
||||
.settingsCard {
|
||||
padding: 24px 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
|
||||
import { Col, Radio, Tooltip } from 'antd';
|
||||
import { ExternalLink, SolidInfoCircle } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
@@ -12,9 +13,7 @@ import {
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import cx from 'classnames';
|
||||
|
||||
import SegmentedControl from '../SegmentedControl/SegmentedControl';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './CrossPanelSync.module.scss';
|
||||
import styles from '../GeneralSettings.module.scss';
|
||||
|
||||
interface CrossPanelSyncProps {
|
||||
dashboardId: string;
|
||||
@@ -27,15 +26,12 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
return (
|
||||
<div className={cx(settingsStyles.settingsCard, styles.crossPanelSyncGroup)}>
|
||||
<Col className={cx(styles.overviewSettings, styles.crossPanelSyncGroup)}>
|
||||
<div className={styles.crossPanelSyncSectionHeader}>
|
||||
<Typography.Text className={styles.crossPanelsSyncSectionTitle}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
withPortal={false}
|
||||
<Tooltip
|
||||
title={
|
||||
<div className={styles.crossPanelSyncTooltipContent}>
|
||||
<strong className={styles.crossPanelSyncTooltipTitle}>
|
||||
@@ -44,7 +40,7 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
<span className={styles.crossPanelSyncTooltipDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</span>
|
||||
<Typography.Link
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -52,14 +48,15 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</Typography.Link>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
@@ -69,18 +66,19 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
testId="cursor-sync-mode"
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={setCursorSyncMode}
|
||||
options={[
|
||||
{ label: 'No Sync', value: DashboardCursorSync.None },
|
||||
{ label: 'Crosshair', value: DashboardCursorSync.Crosshair },
|
||||
{ label: 'Tooltip', value: DashboardCursorSync.Tooltip },
|
||||
]}
|
||||
/>
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
@@ -92,25 +90,24 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<SegmentedControl
|
||||
testId="sync-tooltip-filter-mode"
|
||||
<Radio.Group
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(value): void => {
|
||||
onChange={(e): void => {
|
||||
void logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: value,
|
||||
mode: e.target.value,
|
||||
});
|
||||
setSyncTooltipFilterMode(value);
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
}}
|
||||
options={[
|
||||
{ label: 'All', value: SyncTooltipFilterMode.All },
|
||||
{ label: 'Filtered', value: SyncTooltipFilterMode.Filtered },
|
||||
]}
|
||||
/>
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Select/Input to @signozhq/ui
|
||||
import { Col, Input, Select, Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import styles from '../GeneralSettings.module.scss';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface GeneralFormProps {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
onTitleChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
onImageChange: (value: string) => void;
|
||||
onTagsChange: Dispatch<SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
function GeneralForm({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
tags,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
onImageChange,
|
||||
onTagsChange,
|
||||
}: GeneralFormProps): JSX.Element {
|
||||
return (
|
||||
<Col className={styles.overviewSettings}>
|
||||
<Space direction="vertical" className={styles.formSpace}>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
value={image}
|
||||
onChange={onImageChange}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={title}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={description}
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => onDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<AddTags tags={tags} setTags={onTagsChange} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneralForm;
|
||||
@@ -0,0 +1,238 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
padding: 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.formSpace {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 21px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfoIcon {
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDescription {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDocLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-background);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './UnsavedChangesFooter.module.scss';
|
||||
import styles from '../GeneralSettings.module.scss';
|
||||
|
||||
interface UnsavedChangesFooterProps {
|
||||
count: number;
|
||||
@@ -29,13 +29,13 @@ function UnsavedChangesFooter({
|
||||
{count > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.footerActionButtons}>
|
||||
<div className={styles.footerActionBtns}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
disabled={isSaving}
|
||||
prefix={<X size={14} />}
|
||||
onClick={onDiscard}
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
@@ -47,6 +47,7 @@ function UnsavedChangesFooter({
|
||||
prefix={<Check size={14} />}
|
||||
testId="save-dashboard-config"
|
||||
onClick={onSave}
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
@@ -11,22 +11,22 @@ import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
|
||||
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
|
||||
import GeneralForm from './GeneralForm/GeneralForm';
|
||||
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
|
||||
import { Base64Icons, stringsToTags, tagsToStrings } from './utils';
|
||||
import styles from './Overview.module.scss';
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
|
||||
interface OverviewProps {
|
||||
interface GeneralSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
|
||||
const id = dashboard.id;
|
||||
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
|
||||
const title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
const title = dashboard.spec?.display?.name ?? '';
|
||||
const description = dashboard.spec?.display?.description ?? '';
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
const tagsAsStrings = useMemo(
|
||||
() => tagsToStrings(dashboard.tags ?? []),
|
||||
@@ -64,7 +64,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
value,
|
||||
});
|
||||
|
||||
if (updatedTitle !== title && updatedTitle !== '') {
|
||||
if (updatedTitle !== title) {
|
||||
ops.push(replace('/spec/display/name', updatedTitle));
|
||||
}
|
||||
if (updatedDescription !== description) {
|
||||
@@ -89,6 +89,9 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const ops = buildPatch();
|
||||
if (ops.length === 0) {
|
||||
return;
|
||||
@@ -107,7 +110,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
}, [id, buildPatch, refetch, showErrorModal]);
|
||||
|
||||
useEffect(() => {
|
||||
let numberOfUnsavedChanges = 0;
|
||||
let n = 0;
|
||||
const initialValues = [title, description, tagsAsStrings, image];
|
||||
const updatedValues = [
|
||||
updatedTitle,
|
||||
@@ -117,10 +120,10 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
];
|
||||
initialValues.forEach((val, index) => {
|
||||
if (!isEqual(val, updatedValues[index])) {
|
||||
numberOfUnsavedChanges += 1;
|
||||
n += 1;
|
||||
}
|
||||
});
|
||||
setNumberOfUnsavedChanges(numberOfUnsavedChanges);
|
||||
setNumberOfUnsavedChanges(n);
|
||||
}, [
|
||||
description,
|
||||
image,
|
||||
@@ -141,7 +144,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<DashboardInfoForm
|
||||
<GeneralForm
|
||||
title={updatedTitle}
|
||||
description={updatedDescription}
|
||||
image={updatedImage}
|
||||
@@ -164,4 +167,4 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default Overview;
|
||||
export default GeneralSettings;
|
||||
@@ -1,86 +0,0 @@
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.crossPanelsSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfoIcon {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDescription {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDocLink {
|
||||
color: var(--primary-background);
|
||||
font-size: 12px;
|
||||
margin-top: 16px;
|
||||
vertical-align: middle;
|
||||
|
||||
// typography override
|
||||
--typography-text-display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex: 1 1 80px;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
.formSpace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.infoItemContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.infoTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
[data-radix-popper-content-wrapper] {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l3-background);
|
||||
|
||||
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardImageOptions {
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.dashboardImageSelectItem {
|
||||
width: min-content;
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// the V1 tags input ships borderless; give the field a visible box to match
|
||||
.tagsField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
// background: var(--l3-background);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './DashboardInfoForm.module.scss';
|
||||
|
||||
interface DashboardInfoFormProps {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
onTitleChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
onImageChange: (value: string) => void;
|
||||
onTagsChange: Dispatch<SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
function DashboardInfoForm({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
tags,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
onImageChange,
|
||||
onTagsChange,
|
||||
}: DashboardInfoFormProps): JSX.Element {
|
||||
return (
|
||||
<div className={settingsStyles.settingsCard}>
|
||||
<div className={styles.formSpace}>
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
value={image}
|
||||
onChange={(value): void => onImageChange(value as string)}
|
||||
>
|
||||
<SelectTrigger className={styles.dashboardImageInput} />
|
||||
<SelectContent
|
||||
className={styles.dashboardImageOptions}
|
||||
withPortal={false}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem
|
||||
key={icon}
|
||||
value={icon}
|
||||
className={styles.dashboardImageSelectItem}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
testId="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={title}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Description</Typography>
|
||||
<AntdInput.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={description}
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => onDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Tags</Typography>
|
||||
<div className={styles.tagsField}>
|
||||
<AddTags tags={tags} setTags={onTagsChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardInfoForm;
|
||||
@@ -1,5 +0,0 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
.segmented {
|
||||
// override RadioGroup's default vertical grid; lay segments out connected
|
||||
display: inline-flex;
|
||||
grid-auto-flow: column;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.segment {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// the visible segment is the radio's label (htmlFor-wired, so clicks register)
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 6px 14px;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
// collapse the radio circle into a transparent full-cell click target
|
||||
.segmentInput {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
// hide the default radio dot/indicator
|
||||
* {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// highlight the selected segment as a raised, lighter pill (data-state is a
|
||||
// stable Radix attribute). --l3-background is the lightest layer, so lift it
|
||||
// further with a subtle foreground tint rather than going darker.
|
||||
.segmentInput[data-state='checked'] + label {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
|
||||
import styles from './SegmentedControl.module.scss';
|
||||
|
||||
export interface SegmentedControlOption<T extends string> {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
value: T;
|
||||
options: SegmentedControlOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected pill segmented control composed on top of @signozhq/ui RadioGroup:
|
||||
* the radio circle is collapsed into a transparent full-cell click target and
|
||||
* the label becomes the visible segment (highlighted via the radio's stable
|
||||
* `data-state="checked"`). Keeps radio semantics + keyboard nav.
|
||||
*/
|
||||
function SegmentedControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
testId,
|
||||
}: SegmentedControlProps<T>): JSX.Element {
|
||||
return (
|
||||
<RadioGroup
|
||||
className={styles.segmented}
|
||||
value={value}
|
||||
onChange={(next): void => onChange(next as T)}
|
||||
testId={testId}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<RadioGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
containerClassName={styles.segment}
|
||||
className={styles.segmentInput}
|
||||
testId={testId ? `${testId}-${option.value}` : undefined}
|
||||
>
|
||||
{option.label}
|
||||
</RadioGroupItem>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default SegmentedControl;
|
||||
@@ -1,39 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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;
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
@@ -1,54 +0,0 @@
|
||||
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;
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
@@ -1,49 +0,0 @@
|
||||
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;
|
||||
@@ -1,14 +0,0 @@
|
||||
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' },
|
||||
];
|
||||
@@ -1,71 +0,0 @@
|
||||
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;
|
||||
@@ -1,197 +0,0 @@
|
||||
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,103 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
|
||||
import { Select } from 'antd';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface DynamicVariableFieldsProps {
|
||||
attribute: string;
|
||||
signal: TelemetrySignal;
|
||||
onChange: (patch: {
|
||||
dynamicAttribute?: string;
|
||||
dynamicSignal?: TelemetrySignal;
|
||||
}) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
}
|
||||
|
||||
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
|
||||
function DynamicVariableFields({
|
||||
attribute,
|
||||
signal,
|
||||
onChange,
|
||||
onPreview,
|
||||
}: DynamicVariableFieldsProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const { data: keyData, isLoading } = useGetFieldKeys({
|
||||
signal,
|
||||
name: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
// `keys` is a Record keyed BY field name; the field names are the map keys.
|
||||
// When the API reports the list is `complete`, search filters locally.
|
||||
const isComplete = keyData?.data?.complete === true;
|
||||
const options = useMemo(
|
||||
() =>
|
||||
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
})),
|
||||
[keyData],
|
||||
);
|
||||
|
||||
const { data: valueData } = useGetFieldValues({
|
||||
signal,
|
||||
name: attribute,
|
||||
enabled: !!attribute,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const payload = valueData?.data;
|
||||
const values =
|
||||
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
|
||||
onPreview(values);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [valueData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Source</Typography.Text>
|
||||
</div>
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
value={signal}
|
||||
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as TelemetrySignal })
|
||||
}
|
||||
testId="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Attribute</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a telemetry field"
|
||||
loading={isLoading}
|
||||
filterOption={isComplete}
|
||||
onSearch={setSearch}
|
||||
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
|
||||
options={options}
|
||||
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicVariableFields;
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import Editor from 'components/Editor';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import type { VariableSort } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface QueryVariableFieldsProps {
|
||||
queryValue: string;
|
||||
sort: VariableSort;
|
||||
onChange: (queryValue: string) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
onError: (message: string | null) => void;
|
||||
}
|
||||
|
||||
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
|
||||
function QueryVariableFields({
|
||||
queryValue,
|
||||
sort,
|
||||
onChange,
|
||||
onPreview,
|
||||
onError,
|
||||
}: QueryVariableFieldsProps): JSX.Element {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const runTest = async (): Promise<void> => {
|
||||
setIsRunning(true);
|
||||
onError(null);
|
||||
try {
|
||||
const res = await dashboardVariablesQuery({
|
||||
query: queryValue,
|
||||
variables: {},
|
||||
});
|
||||
if (res.statusCode === 200 && res.payload) {
|
||||
onPreview(
|
||||
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
|
||||
);
|
||||
} else {
|
||||
onError(res.error || 'Failed to run query');
|
||||
onPreview([]);
|
||||
}
|
||||
} catch (err) {
|
||||
onError((err as Error).message || 'Failed to run query');
|
||||
onPreview([]);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.queryContainer}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Query</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.editorWrap}>
|
||||
<Editor
|
||||
language="sql"
|
||||
value={queryValue}
|
||||
onChange={(value): void => onChange(value)}
|
||||
height="240px"
|
||||
options={{
|
||||
fontSize: 13,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
minimap: { enabled: false },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.testRow}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isRunning}
|
||||
disabled={!queryValue}
|
||||
onClick={runTest}
|
||||
testId="variable-test-run"
|
||||
>
|
||||
Test Run Query
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryVariableFields;
|
||||
@@ -1,310 +0,0 @@
|
||||
/* Faithful reproduction of the V1 VariableItem layout, scoped as a module and
|
||||
built on @signozhq components where possible. antd is retained only for the
|
||||
monaco Editor, multiline TextArea, Collapse, and searchable Selects. */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.allVariables {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.allVariablesBtn {
|
||||
--button-height: 24px;
|
||||
--button-padding: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 12px 16px 20px;
|
||||
}
|
||||
|
||||
/* VariableItemRow */
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* LabelContainer */
|
||||
.labelContainer {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea,
|
||||
.defaultInput {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.defaultInput {
|
||||
width: 342px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 12px;
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
/* Variable type segmented group */
|
||||
.typeSection {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.typeLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.typeBtnGroup {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, max-content);
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.typeBtn {
|
||||
--button-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 114px;
|
||||
border-radius: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.typeBtnSelected {
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.betaTag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Query */
|
||||
.queryContainer {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editorWrap {
|
||||
height: 240px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.testRow {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Custom — antd Collapse */
|
||||
.customSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.customSection :global(.custom-collapse) {
|
||||
width: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 12px;
|
||||
background: var(--l3-background);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header-text) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 1px 2px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.comma-input) {
|
||||
height: 109px;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Textbox */
|
||||
.textboxSection {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Preview strip */
|
||||
.previewSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 88px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
border-radius: 3px 0 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
|
||||
}
|
||||
|
||||
.previewValues {
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
gap: 8px;
|
||||
padding: 4.5px 11px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.previewValues [data-slot='badge'] {
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.previewError {
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
/* Sort / multi / all / default rows */
|
||||
.sortSection,
|
||||
.multiSection,
|
||||
.allOptionSection,
|
||||
.dynamicSection {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sortSection {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
width: 339px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.sortSelect {
|
||||
width: 192px;
|
||||
}
|
||||
|
||||
.defaultValueSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.defaultValueSection .label {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.defaultValueDesc {
|
||||
display: block;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
.searchSelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user