mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-16 13:30:36 +01:00
Compare commits
1 Commits
issue_5365
...
chore/bump
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71cbf296b |
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:
|
||||
@@ -3566,6 +3573,10 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/ErrorsResponseerroradditional'
|
||||
type: array
|
||||
invalidReferences:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
message:
|
||||
type: string
|
||||
retry:
|
||||
@@ -3586,10 +3597,6 @@ components:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
suggestions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
ErrorsResponseretryjson:
|
||||
properties:
|
||||
@@ -9004,6 +9011,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -9156,6 +9167,10 @@ paths:
|
||||
$ref: '#/components/schemas/DashboardtypesUpdatablePublicDashboard'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -9750,6 +9765,10 @@ paths:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
@@ -10191,15 +10210,6 @@ paths:
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: q
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: isOverride
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -10943,6 +10953,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11056,6 +11070,10 @@ paths:
|
||||
$ref: '#/components/schemas/AuthtypesPatchableRole'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11202,6 +11220,10 @@ paths:
|
||||
$ref: '#/components/schemas/CoretypesPatchableObjects'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -11651,6 +11673,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11758,6 +11784,10 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -11939,6 +11969,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11996,6 +12030,10 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableFactorAPIKey'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -12178,6 +12216,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -12253,6 +12295,10 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"404":
|
||||
content:
|
||||
@@ -12744,53 +12790,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
|
||||
@@ -13477,6 +13476,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -13736,6 +13739,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -13788,6 +13795,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -15518,6 +15529,10 @@ paths:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesUpdateMetricMetadataRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
@@ -20816,6 +20831,10 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -20863,6 +20882,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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@grafana/data": "^11.6.14",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "8.41.0",
|
||||
"@sentry/vite-plugin": "2.22.6",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
|
||||
283
frontend/pnpm-lock.yaml
generated
283
frontend/pnpm-lock.yaml
generated
@@ -62,11 +62,11 @@ importers:
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@sentry/react':
|
||||
specifier: 8.41.0
|
||||
version: 8.41.0(react@18.2.0)
|
||||
specifier: 10.57.0
|
||||
version: 10.57.0(react@18.2.0)
|
||||
'@sentry/vite-plugin':
|
||||
specifier: 2.22.6
|
||||
version: 2.22.6
|
||||
specifier: 5.3.0
|
||||
version: 5.3.0
|
||||
'@signozhq/design-tokens':
|
||||
specifier: 2.1.4
|
||||
version: 2.1.4
|
||||
@@ -3161,97 +3161,108 @@ packages:
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
'@sentry-internal/browser-utils@8.41.0':
|
||||
resolution: {integrity: sha512-nU7Bn3jEUmf1QXRUT3j2ewUBlFJpe9vnAnjqpeVPDWTsVI52BwVNcJHuE37PrGs66OZ1ZkGMfKnQk43oCAa+oQ==}
|
||||
engines: {node: '>=14.18'}
|
||||
'@sentry-internal/browser-utils@10.57.0':
|
||||
resolution: {integrity: sha512-tXObp954rMTSYKlbftjVXHtNl4t/6ssks3jkqyzmKb+PDPWzabGQO7sWwqVuTjT8Kx/8A3FmriS1bGmqxiJy3A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/feedback@8.41.0':
|
||||
resolution: {integrity: sha512-bw+BrSNw8abOnu/IpD8YSbYubXkkT8jyNS7TM4e4UPZMuXcbtia7/r5d7kAiUfKv/sV5PNMlZLOk+EYJeLTANg==}
|
||||
engines: {node: '>=14.18'}
|
||||
'@sentry-internal/feedback@10.57.0':
|
||||
resolution: {integrity: sha512-ZcF4QhkqGX3iiQSXB2N0N3Awp+j5iqnDRu6PA/qyLFrWqH5ZiiAAgu59OLD9E6XAdg6iFtLYw19MAMZVK8qNOQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay-canvas@8.41.0':
|
||||
resolution: {integrity: sha512-lpgOBHWr1ZNxidD72A2pfoUMjIpwonOPYoQZWAHr86Oa3eIVQOyfklZlHW+gKPFl2/IEl9Lbtcke0JiDp3dkIQ==}
|
||||
engines: {node: '>=14.18'}
|
||||
'@sentry-internal/replay-canvas@10.57.0':
|
||||
resolution: {integrity: sha512-zsfa4JcfV0AEc9YhNxNabd5lSZL2Av84saAyexGAqcHs+67m9Gd0cGStOzMb/nCl7UAtmdP0aI+G7a3rcxxN/A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay@8.41.0':
|
||||
resolution: {integrity: sha512-ByXEY7JI95y4Qr9fS3d28l9uuVU5Qa0HgL+xDmYElNx7CXz3Q9hFN6ibgUeC3h8BO5pDULxWNgAppl7FRY8N5w==}
|
||||
engines: {node: '>=14.18'}
|
||||
'@sentry-internal/replay@10.57.0':
|
||||
resolution: {integrity: sha512-Wmnx/6ABynVH1iwuoNUqJNyjIUqsqoGML7qsyivBRKb5Wo2YQtPOQlQYfxfZSvWzGpcoSVdInkRjDssUQxQEQg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/babel-plugin-component-annotate@2.22.6':
|
||||
resolution: {integrity: sha512-V2g1Y1I5eSe7dtUVMBvAJr8BaLRr4CLrgNgtPaZyMT4Rnps82SrZ5zqmEkLXPumlXhLUWR6qzoMNN2u+RXVXfQ==}
|
||||
engines: {node: '>= 14'}
|
||||
'@sentry/babel-plugin-component-annotate@5.3.0':
|
||||
resolution: {integrity: sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@sentry/browser@8.41.0':
|
||||
resolution: {integrity: sha512-FfAU55eYwW2lG4M3dEw2472RvHrD5YWSfHCZvuRf/4skX38kFvKghZQ+epL+CVHTzvIRHOrbj8qQK6YLTGl9ew==}
|
||||
engines: {node: '>=14.18'}
|
||||
'@sentry/browser@10.57.0':
|
||||
resolution: {integrity: sha512-s36AQy/CKXTfyY9Z+qUhzNomntZXgfs0rbaK7q9ffnFkqcPwzE8qQtVs58y3Suut56u+AhwSztgQtERcuZ5VIA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/bundler-plugin-core@2.22.6':
|
||||
resolution: {integrity: sha512-1esQdgSUCww9XAntO4pr7uAM5cfGhLsgTK9MEwAKNfvpMYJi9NUTYa3A7AZmdA8V6107Lo4OD7peIPrDRbaDCg==}
|
||||
engines: {node: '>= 14'}
|
||||
'@sentry/bundler-plugin-core@5.3.0':
|
||||
resolution: {integrity: sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@sentry/cli-darwin@2.39.1':
|
||||
resolution: {integrity: sha512-kiNGNSAkg46LNGatfNH5tfsmI/kCAaPA62KQuFZloZiemTNzhy9/6NJP8HZ/GxGs8GDMxic6wNrV9CkVEgFLJQ==}
|
||||
'@sentry/cli-darwin@2.58.6':
|
||||
resolution: {integrity: sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==}
|
||||
engines: {node: '>=10'}
|
||||
os: [darwin]
|
||||
|
||||
'@sentry/cli-linux-arm64@2.39.1':
|
||||
resolution: {integrity: sha512-5VbVJDatolDrWOgaffsEM7znjs0cR8bHt9Bq0mStM3tBolgAeSDHE89NgHggfZR+DJ2VWOy4vgCwkObrUD6NQw==}
|
||||
'@sentry/cli-linux-arm64@2.58.6':
|
||||
resolution: {integrity: sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux, freebsd]
|
||||
os: [linux, freebsd, android]
|
||||
|
||||
'@sentry/cli-linux-arm@2.39.1':
|
||||
resolution: {integrity: sha512-DkENbxyRxUrfLnJLXTA4s5UL/GoctU5Cm4ER1eB7XN7p9WsamFJd/yf2KpltkjEyiTuplv0yAbdjl1KX3vKmEQ==}
|
||||
'@sentry/cli-linux-arm@2.58.6':
|
||||
resolution: {integrity: sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm]
|
||||
os: [linux, freebsd]
|
||||
os: [linux, freebsd, android]
|
||||
|
||||
'@sentry/cli-linux-i686@2.39.1':
|
||||
resolution: {integrity: sha512-pXWVoKXCRrY7N8vc9H7mETiV9ZCz+zSnX65JQCzZxgYrayQPJTc+NPRnZTdYdk5RlAupXaFicBI2GwOCRqVRkg==}
|
||||
'@sentry/cli-linux-i686@2.58.6':
|
||||
resolution: {integrity: sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x86, ia32]
|
||||
os: [linux, freebsd]
|
||||
os: [linux, freebsd, android]
|
||||
|
||||
'@sentry/cli-linux-x64@2.39.1':
|
||||
resolution: {integrity: sha512-IwayNZy+it7FWG4M9LayyUmG1a/8kT9+/IEm67sT5+7dkMIMcpmHDqL8rWcPojOXuTKaOBBjkVdNMBTXy0mXlA==}
|
||||
'@sentry/cli-linux-x64@2.58.6':
|
||||
resolution: {integrity: sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux, freebsd]
|
||||
os: [linux, freebsd, android]
|
||||
|
||||
'@sentry/cli-win32-i686@2.39.1':
|
||||
resolution: {integrity: sha512-NglnNoqHSmE+Dz/wHeIVRnV2bLMx7tIn3IQ8vXGO5HWA2f8zYJGktbkLq1Lg23PaQmeZLPGlja3gBQfZYSG10Q==}
|
||||
'@sentry/cli-win32-arm64@2.58.6':
|
||||
resolution: {integrity: sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@sentry/cli-win32-i686@2.58.6':
|
||||
resolution: {integrity: sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x86, ia32]
|
||||
os: [win32]
|
||||
|
||||
'@sentry/cli-win32-x64@2.39.1':
|
||||
resolution: {integrity: sha512-xv0R2CMf/X1Fte3cMWie1NXuHmUyQPDBfCyIt6k6RPFPxAYUgcqgMPznYwVMwWEA1W43PaOkSn3d8ZylsDaETw==}
|
||||
'@sentry/cli-win32-x64@2.58.6':
|
||||
resolution: {integrity: sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@sentry/cli@2.39.1':
|
||||
resolution: {integrity: sha512-JIb3e9vh0+OmQ0KxmexMXg9oZsR/G7HMwxt5BUIKAXZ9m17Xll4ETXTRnRUBT3sf7EpNGAmlQk1xEmVN9pYZYQ==}
|
||||
'@sentry/cli@2.58.6':
|
||||
resolution: {integrity: sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==}
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@sentry/core@8.41.0':
|
||||
resolution: {integrity: sha512-3v7u3t4LozCA5SpZY4yqUN2U3jSrkXNoLgz6L2SUUiydyCuSwXZIFEwpLJfgQyidpNDifeQbBI5E1O910XkPsA==}
|
||||
engines: {node: '>=14.18'}
|
||||
'@sentry/core@10.57.0':
|
||||
resolution: {integrity: sha512-kntItTA2kiT0YpL7encXaF6mkdZMB+y48lwj8w1wkfBpfJAC7sifdgrzLQZqmsqVNE3crg9VfufaAGA+78uFMg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/react@8.41.0':
|
||||
resolution: {integrity: sha512-/7LEWDNdICYO5s4ie8ztgpmD/GRJ1+1nHlSKvcwjf83COzT1eGvVeuYTiXFAPmXA29sY+lV1RajziwgySadjIQ==}
|
||||
engines: {node: '>=14.18'}
|
||||
'@sentry/react@10.57.0':
|
||||
resolution: {integrity: sha512-6QThwQ4XWQ2rwKZEVQ9P9WKl7JlowC7S5LpAvmMdrwlfJBpLDFOsM7tycnIvbXTXf0ZOOuLFPa4L4YYbdyNGmA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.14.0 || 17.x || 18.x || 19.x
|
||||
|
||||
'@sentry/types@8.41.0':
|
||||
resolution: {integrity: sha512-eqdnGr9k9H++b9CjVUoTNUVahPVWeNnMy0YGkqS5+cjWWC+x43p56202oidGFmWo6702ub/xwUNH6M5PC4kq6A==}
|
||||
engines: {node: '>=14.18'}
|
||||
'@sentry/rollup-plugin@5.3.0':
|
||||
resolution: {integrity: sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
rollup: '>=3.2.0'
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
'@sentry/vite-plugin@2.22.6':
|
||||
resolution: {integrity: sha512-zIieP1VLWQb3wUjFJlwOAoaaJygJhXeUoGd0e/Ha2RLb2eW2S+4gjf6y6NqyY71tZ74LYVZKg/4prB6FAZSMXQ==}
|
||||
engines: {node: '>= 14'}
|
||||
'@sentry/vite-plugin@5.3.0':
|
||||
resolution: {integrity: sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
||||
@@ -5290,11 +5301,6 @@ packages:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
glob@9.3.5:
|
||||
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global-directory@5.0.0:
|
||||
resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -6603,10 +6609,6 @@ packages:
|
||||
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
minimatch@8.0.7:
|
||||
resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimatch@9.0.9:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -6614,10 +6616,6 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minipass@4.2.8:
|
||||
resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
minipass@7.1.3:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -8619,9 +8617,6 @@ packages:
|
||||
unload@2.2.0:
|
||||
resolution: {integrity: sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==}
|
||||
|
||||
unplugin@1.0.1:
|
||||
resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==}
|
||||
|
||||
unrs-resolver@1.11.1:
|
||||
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
|
||||
|
||||
@@ -8815,13 +8810,6 @@ packages:
|
||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
webpack-sources@3.2.3:
|
||||
resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
webpack-virtual-modules@0.5.0:
|
||||
resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==}
|
||||
|
||||
whatwg-encoding@2.0.0:
|
||||
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -11892,75 +11880,72 @@ snapshots:
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@sentry-internal/browser-utils@8.41.0':
|
||||
'@sentry-internal/browser-utils@10.57.0':
|
||||
dependencies:
|
||||
'@sentry/core': 8.41.0
|
||||
'@sentry/types': 8.41.0
|
||||
'@sentry/core': 10.57.0
|
||||
|
||||
'@sentry-internal/feedback@8.41.0':
|
||||
'@sentry-internal/feedback@10.57.0':
|
||||
dependencies:
|
||||
'@sentry/core': 8.41.0
|
||||
'@sentry/types': 8.41.0
|
||||
'@sentry/core': 10.57.0
|
||||
|
||||
'@sentry-internal/replay-canvas@8.41.0':
|
||||
'@sentry-internal/replay-canvas@10.57.0':
|
||||
dependencies:
|
||||
'@sentry-internal/replay': 8.41.0
|
||||
'@sentry/core': 8.41.0
|
||||
'@sentry/types': 8.41.0
|
||||
'@sentry-internal/replay': 10.57.0
|
||||
'@sentry/core': 10.57.0
|
||||
|
||||
'@sentry-internal/replay@8.41.0':
|
||||
'@sentry-internal/replay@10.57.0':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 8.41.0
|
||||
'@sentry/core': 8.41.0
|
||||
'@sentry/types': 8.41.0
|
||||
'@sentry-internal/browser-utils': 10.57.0
|
||||
'@sentry/core': 10.57.0
|
||||
|
||||
'@sentry/babel-plugin-component-annotate@2.22.6': {}
|
||||
'@sentry/babel-plugin-component-annotate@5.3.0': {}
|
||||
|
||||
'@sentry/browser@8.41.0':
|
||||
'@sentry/browser@10.57.0':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 8.41.0
|
||||
'@sentry-internal/feedback': 8.41.0
|
||||
'@sentry-internal/replay': 8.41.0
|
||||
'@sentry-internal/replay-canvas': 8.41.0
|
||||
'@sentry/core': 8.41.0
|
||||
'@sentry/types': 8.41.0
|
||||
'@sentry-internal/browser-utils': 10.57.0
|
||||
'@sentry-internal/feedback': 10.57.0
|
||||
'@sentry-internal/replay': 10.57.0
|
||||
'@sentry-internal/replay-canvas': 10.57.0
|
||||
'@sentry/core': 10.57.0
|
||||
|
||||
'@sentry/bundler-plugin-core@2.22.6':
|
||||
'@sentry/bundler-plugin-core@5.3.0':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@sentry/babel-plugin-component-annotate': 2.22.6
|
||||
'@sentry/cli': 2.39.1
|
||||
'@sentry/babel-plugin-component-annotate': 5.3.0
|
||||
'@sentry/cli': 2.58.6
|
||||
dotenv: 16.6.1
|
||||
find-up: 5.0.0
|
||||
glob: 9.3.5
|
||||
glob: 13.0.6
|
||||
magic-string: 0.30.8
|
||||
unplugin: 1.0.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@sentry/cli-darwin@2.39.1':
|
||||
'@sentry/cli-darwin@2.58.6':
|
||||
optional: true
|
||||
|
||||
'@sentry/cli-linux-arm64@2.39.1':
|
||||
'@sentry/cli-linux-arm64@2.58.6':
|
||||
optional: true
|
||||
|
||||
'@sentry/cli-linux-arm@2.39.1':
|
||||
'@sentry/cli-linux-arm@2.58.6':
|
||||
optional: true
|
||||
|
||||
'@sentry/cli-linux-i686@2.39.1':
|
||||
'@sentry/cli-linux-i686@2.58.6':
|
||||
optional: true
|
||||
|
||||
'@sentry/cli-linux-x64@2.39.1':
|
||||
'@sentry/cli-linux-x64@2.58.6':
|
||||
optional: true
|
||||
|
||||
'@sentry/cli-win32-i686@2.39.1':
|
||||
'@sentry/cli-win32-arm64@2.58.6':
|
||||
optional: true
|
||||
|
||||
'@sentry/cli-win32-x64@2.39.1':
|
||||
'@sentry/cli-win32-i686@2.58.6':
|
||||
optional: true
|
||||
|
||||
'@sentry/cli@2.39.1':
|
||||
'@sentry/cli-win32-x64@2.58.6':
|
||||
optional: true
|
||||
|
||||
'@sentry/cli@2.58.6':
|
||||
dependencies:
|
||||
https-proxy-agent: 5.0.1
|
||||
node-fetch: 2.7.0
|
||||
@@ -11968,39 +11953,43 @@ snapshots:
|
||||
proxy-from-env: 1.1.0
|
||||
which: 2.0.2
|
||||
optionalDependencies:
|
||||
'@sentry/cli-darwin': 2.39.1
|
||||
'@sentry/cli-linux-arm': 2.39.1
|
||||
'@sentry/cli-linux-arm64': 2.39.1
|
||||
'@sentry/cli-linux-i686': 2.39.1
|
||||
'@sentry/cli-linux-x64': 2.39.1
|
||||
'@sentry/cli-win32-i686': 2.39.1
|
||||
'@sentry/cli-win32-x64': 2.39.1
|
||||
'@sentry/cli-darwin': 2.58.6
|
||||
'@sentry/cli-linux-arm': 2.58.6
|
||||
'@sentry/cli-linux-arm64': 2.58.6
|
||||
'@sentry/cli-linux-i686': 2.58.6
|
||||
'@sentry/cli-linux-x64': 2.58.6
|
||||
'@sentry/cli-win32-arm64': 2.58.6
|
||||
'@sentry/cli-win32-i686': 2.58.6
|
||||
'@sentry/cli-win32-x64': 2.58.6
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@sentry/core@8.41.0':
|
||||
dependencies:
|
||||
'@sentry/types': 8.41.0
|
||||
'@sentry/core@10.57.0': {}
|
||||
|
||||
'@sentry/react@8.41.0(react@18.2.0)':
|
||||
'@sentry/react@10.57.0(react@18.2.0)':
|
||||
dependencies:
|
||||
'@sentry/browser': 8.41.0
|
||||
'@sentry/core': 8.41.0
|
||||
'@sentry/types': 8.41.0
|
||||
hoist-non-react-statics: 3.3.2
|
||||
'@sentry/browser': 10.57.0
|
||||
'@sentry/core': 10.57.0
|
||||
react: 18.2.0
|
||||
|
||||
'@sentry/types@8.41.0': {}
|
||||
|
||||
'@sentry/vite-plugin@2.22.6':
|
||||
'@sentry/rollup-plugin@5.3.0':
|
||||
dependencies:
|
||||
'@sentry/bundler-plugin-core': 2.22.6
|
||||
unplugin: 1.0.1
|
||||
'@sentry/bundler-plugin-core': 5.3.0
|
||||
magic-string: 0.30.8
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@sentry/vite-plugin@5.3.0':
|
||||
dependencies:
|
||||
'@sentry/bundler-plugin-core': 5.3.0
|
||||
'@sentry/rollup-plugin': 5.3.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
@@ -14307,13 +14296,6 @@ snapshots:
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
glob@9.3.5:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
minimatch: 8.0.7
|
||||
minipass: 4.2.8
|
||||
path-scurry: 1.11.1
|
||||
|
||||
global-directory@5.0.0:
|
||||
dependencies:
|
||||
ini: 6.0.0
|
||||
@@ -16071,18 +16053,12 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimatch@8.0.7:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minipass@4.2.8: {}
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
moment-timezone@0.5.47:
|
||||
@@ -18347,13 +18323,6 @@ snapshots:
|
||||
'@babel/runtime': 7.28.2
|
||||
detect-node: 2.1.0
|
||||
|
||||
unplugin@1.0.1:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
chokidar: 3.6.0
|
||||
webpack-sources: 3.2.3
|
||||
webpack-virtual-modules: 0.5.0
|
||||
|
||||
unrs-resolver@1.11.1:
|
||||
dependencies:
|
||||
napi-postinstall: 0.3.4
|
||||
@@ -18573,10 +18542,6 @@ snapshots:
|
||||
|
||||
webidl-conversions@7.0.0: {}
|
||||
|
||||
webpack-sources@3.2.3: {}
|
||||
|
||||
webpack-virtual-modules@0.5.0: {}
|
||||
|
||||
whatwg-encoding@2.0.0:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
|
||||
@@ -63,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,
|
||||
@@ -346,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' },
|
||||
@@ -836,7 +836,7 @@ export const deleteDashboardV2 = (
|
||||
{ id }: DeleteDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1214,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,
|
||||
@@ -1293,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,
|
||||
@@ -1471,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,
|
||||
@@ -1550,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
|
||||
*/
|
||||
@@ -9388,16 +9398,6 @@ export type ListLLMPricingRulesParams = {
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
q?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
* @description undefined
|
||||
*/
|
||||
isOverride?: boolean | null;
|
||||
};
|
||||
|
||||
export type ListLLMPricingRules200 = {
|
||||
@@ -9746,19 +9746,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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1135,9 +1135,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,91 +1,45 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
import {
|
||||
TabItemProps,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import { TabItemProps, Tabs } from '@signozhq/ui/tabs';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import Overview from './Overview';
|
||||
import PublicDashboardSettings from './PublicDashboard';
|
||||
import GeneralSettings from './General';
|
||||
import { SettingsTabPlaceholder } from './utils';
|
||||
import VariablesSettings from './Variables';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
|
||||
interface DashboardSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
enum TabKeys {
|
||||
OVERVIEW = 'Overview',
|
||||
VARIABLES = 'Variables',
|
||||
PUBLISH = 'Publish',
|
||||
}
|
||||
|
||||
const prefixIcons: Record<TabKeys, JSX.Element> = {
|
||||
[TabKeys.OVERVIEW]: <Table size={14} />,
|
||||
[TabKeys.VARIABLES]: <Braces size={14} />,
|
||||
[TabKeys.PUBLISH]: <Globe size={14} />,
|
||||
};
|
||||
|
||||
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
|
||||
|
||||
const items: TabItemProps[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: TabKeys.OVERVIEW,
|
||||
label: TabKeys.OVERVIEW,
|
||||
children: <Overview dashboard={dashboard} />,
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
children: <GeneralSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Table size={14} />,
|
||||
},
|
||||
{
|
||||
key: TabKeys.VARIABLES,
|
||||
label: TabKeys.VARIABLES,
|
||||
key: 'variables',
|
||||
label: 'Variables',
|
||||
children: <VariablesSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Braces size={14} />,
|
||||
},
|
||||
...(enablePublicDashboard
|
||||
? [
|
||||
{
|
||||
key: TabKeys.PUBLISH,
|
||||
label: TabKeys.PUBLISH,
|
||||
children: <PublicDashboardSettings dashboard={dashboard} />,
|
||||
disabled: user?.role !== USER_ROLES.ADMIN,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'public-dashboard',
|
||||
label: 'Publish',
|
||||
children: (
|
||||
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
prefixIcon: <Globe size={14} />,
|
||||
},
|
||||
],
|
||||
[enablePublicDashboard, dashboard, user?.role],
|
||||
[dashboard],
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
|
||||
<TabsList variant="primary">
|
||||
{Object.values(TabKeys).map((key) => (
|
||||
<TabsTrigger value={key} key={key}>
|
||||
{prefixIcons[key]}
|
||||
{key}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{items.map((item) => (
|
||||
<TabsContent value={item.key} key={item.key} className={styles.tabsContent}>
|
||||
{item.children}
|
||||
</TabsContent>
|
||||
))}
|
||||
</TabsRoot>
|
||||
);
|
||||
return <Tabs defaultValue="general" items={items} />;
|
||||
}
|
||||
|
||||
export default DashboardSettings;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Empty } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
|
||||
/**
|
||||
* TEMPORARY: stand-in for the not-yet-built Variables / Publish settings tabs.
|
||||
* Will be cleaned up later once those tabs ship their real content.
|
||||
*/
|
||||
export function SettingsTabPlaceholder({
|
||||
message,
|
||||
}: {
|
||||
message: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={styles.placeholder}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<Typography.Text>{message}</Typography.Text>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.dashboardBreadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
padding-left: 8px;
|
||||
|
||||
.linkToPreviousPage {
|
||||
// Collapse the design-system Button's fixed-height/padding box so it hugs
|
||||
// the label like inline text (the breadcrumb is text, not a chunky button).
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-gap: 4px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.currentPage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0px 2px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.currentPage:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
|
||||
.dashboardIconImage {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import getSessionStorageApi from 'api/browser/sessionstorage/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import styles from './DashboardBreadcrumbs.module.scss';
|
||||
|
||||
interface DashboardBreadcrumbsProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardBreadcrumbs({
|
||||
title,
|
||||
image,
|
||||
}: DashboardBreadcrumbsProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const goToListPage = useCallback(() => {
|
||||
const dashboardsListQueryParamsString = getSessionStorageApi(
|
||||
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (dashboardsListQueryParamsString) {
|
||||
safeNavigate({
|
||||
pathname: ROUTES.ALL_DASHBOARD,
|
||||
search: `?${dashboardsListQueryParamsString}`,
|
||||
});
|
||||
} else {
|
||||
safeNavigate(ROUTES.ALL_DASHBOARD);
|
||||
}
|
||||
}, [safeNavigate]);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardBreadcrumbs}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<LayoutGrid size={14} />}
|
||||
onClick={goToListPage}
|
||||
className={styles.linkToPreviousPage}
|
||||
testId="dashboard-breadcrumb-list"
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
<div>/</div>
|
||||
<div className={styles.currentPage}>
|
||||
<img
|
||||
src={image}
|
||||
alt="dashboard-icon"
|
||||
className={styles.dashboardIconImage}
|
||||
/>
|
||||
<Typography.Text>{title}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardBreadcrumbs;
|
||||
@@ -0,0 +1,9 @@
|
||||
.dashboardHeader {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
|
||||
|
||||
import styles from './DashboardHeader.module.scss';
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardHeader({ title, image }: DashboardHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.dashboardHeader}>
|
||||
<DashboardBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardHeader);
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
import getSessionStorageApi from 'api/browser/sessionstorage/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from '@signozhq/ui/breadcrumb';
|
||||
|
||||
interface DashboardPageBreadcrumbsProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardPageBreadcrumbs({
|
||||
title,
|
||||
image,
|
||||
}: DashboardPageBreadcrumbsProps): JSX.Element {
|
||||
const dashboardPageLink = useMemo(() => {
|
||||
const dashboardsListQueryParamsString = getSessionStorageApi(
|
||||
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
|
||||
);
|
||||
|
||||
return dashboardsListQueryParamsString
|
||||
? `${ROUTES.ALL_DASHBOARD}?${dashboardsListQueryParamsString}`
|
||||
: ROUTES.ALL_DASHBOARD;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink icon={<LayoutGrid size={14} />} href={dashboardPageLink}>
|
||||
Dashboard
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink icon={<img src={image} alt="dashboard-icon" />}>
|
||||
{title}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageBreadcrumbs;
|
||||
@@ -1,9 +0,0 @@
|
||||
.dashboardPageHeader {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-left: 14px;
|
||||
height: 48px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
|
||||
|
||||
import styles from './DashboardPageHeader.module.scss';
|
||||
|
||||
interface DashboardPageHeaderProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardPageHeader({
|
||||
title,
|
||||
image,
|
||||
}: DashboardPageHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.dashboardPageHeader}>
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardPageHeader);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -6,12 +6,10 @@ import PanelTypeSelectionModal from 'container/DashboardContainer/PanelTypeSelec
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import DashboardPageToolbar from './DashboardPageToolbar';
|
||||
import DashboardDescription from './DashboardDescription';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
|
||||
import { Base64Icons } from './DashboardSettings/Overview/utils';
|
||||
|
||||
interface DashboardContainerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -22,49 +20,32 @@ function DashboardContainer({
|
||||
dashboard,
|
||||
refetch,
|
||||
}: DashboardContainerProps): JSX.Element {
|
||||
useEffect(() => {
|
||||
document.title = dashboard.name;
|
||||
}, [dashboard.name]);
|
||||
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboardPermission] = useComponentPermission(
|
||||
['edit_dashboard'],
|
||||
user.role,
|
||||
);
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const isEditable = !dashboard.locked && editDashboard;
|
||||
|
||||
// Publish edit context to the store so hooks/components read it from there
|
||||
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
|
||||
const setEditContext = useDashboardStore((s) => s.setEditContext);
|
||||
useEffect(() => {
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isEditable: !dashboard.locked && editDashboardPermission,
|
||||
refetch,
|
||||
});
|
||||
}, [
|
||||
dashboard.id,
|
||||
dashboard.locked,
|
||||
editDashboardPermission,
|
||||
refetch,
|
||||
setEditContext,
|
||||
]);
|
||||
setEditContext({ dashboardId: dashboard.id ?? '', isEditable, refetch });
|
||||
}, [dashboard.id, isEditable, refetch, setEditContext]);
|
||||
|
||||
const spec = dashboard.spec;
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
const name = spec.display.name;
|
||||
const { spec } = dashboard;
|
||||
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
|
||||
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);
|
||||
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
<DashboardPageToolbar
|
||||
<DashboardDescription
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
|
||||
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
|
||||
</div>
|
||||
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
|
||||
trigger; navigates to the widget editor route on selection. */}
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { GridItem } from './utils';
|
||||
* intentionally side-effect-free (no React, no network) so they can be unit
|
||||
* tested and reused by the layout hooks. JSON pointers target the postable
|
||||
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
|
||||
* patches in DashboardSettings/Overview and DashboardDescription).
|
||||
* patches in DashboardSettings/General and DashboardDescription).
|
||||
*/
|
||||
|
||||
const { add, replace, remove } = DashboardtypesPatchOpDTO;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -15,6 +16,13 @@ function DashboardPageV2(): JSX.Element {
|
||||
});
|
||||
|
||||
const dashboard = data?.data;
|
||||
const name = dashboard?.spec?.display?.name;
|
||||
|
||||
useEffect(() => {
|
||||
if (name) {
|
||||
document.title = name;
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
.settings-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.settings-page-header {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
backdrop-filter: blur(20px);
|
||||
@@ -28,14 +24,13 @@
|
||||
}
|
||||
|
||||
.settings-page-content-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
align-items: flex-start;
|
||||
|
||||
.settings-page-sidenav {
|
||||
width: 240px;
|
||||
height: calc(100vh - 48px);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
padding-top: var(--padding-1);
|
||||
@@ -79,6 +74,7 @@
|
||||
|
||||
.settings-page-content {
|
||||
flex: 1;
|
||||
height: calc(100vh - 48px);
|
||||
background: var(--l1-background);
|
||||
padding: 10px 8px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -82,6 +82,11 @@ export default defineConfig(({ mode }): UserConfig => {
|
||||
];
|
||||
|
||||
if (env.VITE_SENTRY_AUTH_TOKEN) {
|
||||
if (!env.VITE_SENTRY_ORG || !env.VITE_SENTRY_PROJECT_ID) {
|
||||
throw new Error(
|
||||
'VITE_SENTRY_ORG and VITE_SENTRY_PROJECT_ID must be defined when VITE_SENTRY_AUTH_TOKEN is present.',
|
||||
);
|
||||
}
|
||||
// Refuse to upload sourcemaps without an explicit version.
|
||||
if (!env.VITE_VERSION) {
|
||||
throw new Error(
|
||||
|
||||
@@ -2,7 +2,6 @@ package sqlalertmanagerstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
@@ -40,20 +39,16 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plannedMaintenances := make([]*alertmanagertypes.PlannedMaintenance, 0, len(gettableMaintenancesRules))
|
||||
gettablePlannedMaintenance := make([]*alertmanagertypes.PlannedMaintenance, 0)
|
||||
for _, gettableMaintenancesRule := range gettableMaintenancesRules {
|
||||
pm, err := gettableMaintenancesRule.ToPlannedMaintenance()
|
||||
if err != nil {
|
||||
// Don't return an error because we want to process all the valid records.
|
||||
// Log and skip instead.
|
||||
r.logger.WarnContext(ctx, "skipping planned maintenance", slog.String("maintenance_id", gettableMaintenancesRule.ID.StringValue()), errors.Attr(err))
|
||||
continue
|
||||
m := gettableMaintenancesRule.ToPlannedMaintenance()
|
||||
gettablePlannedMaintenance = append(gettablePlannedMaintenance, m)
|
||||
if m.HasScheduleRecurrenceBoundsMismatch() {
|
||||
r.logger.WarnContext(ctx, "planned_downtime_recurrence_schedule_mismatch", slog.String("maintenance_id", m.ID.StringValue()))
|
||||
}
|
||||
|
||||
plannedMaintenances = append(plannedMaintenances, pm)
|
||||
}
|
||||
|
||||
return plannedMaintenances, nil
|
||||
return gettablePlannedMaintenance, nil
|
||||
}
|
||||
|
||||
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
@@ -69,7 +64,7 @@ func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.U
|
||||
return nil, r.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "planned maintenance with ID: %s does not exist", id.StringValue())
|
||||
}
|
||||
|
||||
return storableMaintenanceRule.ToPlannedMaintenance()
|
||||
return storableMaintenanceRule.ToPlannedMaintenance(), nil
|
||||
}
|
||||
|
||||
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
@@ -78,11 +73,6 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return nil, err
|
||||
}
|
||||
|
||||
schedule, err := json.Marshal(maintenance.Schedule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
@@ -97,7 +87,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
},
|
||||
Name: maintenance.Name,
|
||||
Description: maintenance.Description,
|
||||
Schedule: string(schedule),
|
||||
Schedule: maintenance.Schedule,
|
||||
OrgID: claims.OrgID,
|
||||
Scope: maintenance.Scope,
|
||||
}
|
||||
@@ -145,21 +135,18 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pm := &alertmanagertypes.PlannedMaintenance{
|
||||
return &alertmanagertypes.PlannedMaintenance{
|
||||
ID: storablePlannedMaintenance.ID,
|
||||
Name: storablePlannedMaintenance.Name,
|
||||
Description: storablePlannedMaintenance.Description,
|
||||
Schedule: storablePlannedMaintenance.Schedule,
|
||||
RuleIDs: maintenance.AlertIds,
|
||||
Scope: maintenance.Scope,
|
||||
CreatedAt: storablePlannedMaintenance.CreatedAt,
|
||||
CreatedBy: storablePlannedMaintenance.CreatedBy,
|
||||
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
|
||||
UpdatedBy: storablePlannedMaintenance.UpdatedBy,
|
||||
}
|
||||
if err = json.Unmarshal([]byte(storablePlannedMaintenance.Schedule), &pm.Schedule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pm, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
|
||||
@@ -187,11 +174,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return err
|
||||
}
|
||||
|
||||
schedule, err := json.Marshal(maintenance.Schedule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: id,
|
||||
@@ -206,7 +188,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
},
|
||||
Name: maintenance.Name,
|
||||
Description: maintenance.Description,
|
||||
Schedule: string(schedule),
|
||||
Schedule: maintenance.Schedule,
|
||||
OrgID: claims.OrgID,
|
||||
Scope: maintenance.Scope,
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
package sqlalertmanagerstore
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
store, err := sqlitesqlstore.New(t.Context(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{
|
||||
MaxOpenConns: 1,
|
||||
MaxConnLifetime: 0,
|
||||
},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: filepath.Join(t.TempDir(), "test.db"),
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*alertmanagertypes.StorablePlannedMaintenance)(nil)).
|
||||
IfNotExists().
|
||||
Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*alertmanagertypes.StorablePlannedMaintenanceRule)(nil)).
|
||||
IfNotExists().
|
||||
Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// TestListPlannedMaintenanceSkipsInvalid asserts that a single corrupt record
|
||||
// (here, an unloadable timezone) is skipped rather than failing the whole list.
|
||||
func TestListPlannedMaintenanceSkipsInvalid(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
now := time.Now().UTC()
|
||||
|
||||
valid := &alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
Name: "valid",
|
||||
Schedule: `{"timezone":"UTC","startTime":"2024-01-01T12:00:00Z","recurrence":{"duration":"2h","repeatType":"daily"}}`,
|
||||
OrgID: orgID,
|
||||
}
|
||||
result, err := store.BunDB().NewInsert().Model(valid).Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAffected)
|
||||
|
||||
// A schedule with "zero" startTime
|
||||
invalid := &alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
Name: "invalid",
|
||||
Schedule: `{"timezone":"UTC","recurrence":{"duration":"2h","repeatType":"daily"}}`,
|
||||
OrgID: orgID,
|
||||
}
|
||||
result, err = store.BunDB().NewInsert().Model(invalid).Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
rowsAffected, err = result.RowsAffected()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAffected)
|
||||
|
||||
maintenanceStore := NewMaintenanceStore(store, factorytest.NewSettings())
|
||||
|
||||
list, err := maintenanceStore.ListPlannedMaintenance(t.Context(), orgID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, valid.ID, list[0].ID)
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package alertmanager
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -16,23 +14,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const prefix = "SIGNOZ_"
|
||||
|
||||
// clearSignozEnv unsets all existing SIGNOZ_* env vars for the duration of the test.
|
||||
func clearSignozEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, kv := range os.Environ() {
|
||||
if strings.HasPrefix(kv, prefix) {
|
||||
key := strings.SplitN(kv, "=", 2)[0]
|
||||
orig, _ := os.LookupEnv(key)
|
||||
_ = os.Unsetenv(key)
|
||||
t.Cleanup(func() { _ = os.Setenv(key, orig) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithEnvProvider(t *testing.T) {
|
||||
clearSignozEnv(t)
|
||||
t.Setenv("SIGNOZ_ALERTMANAGER_PROVIDER", "signoz")
|
||||
t.Setenv("SIGNOZ_ALERTMANAGER_LEGACY_API__URL", "http://localhost:9093/api")
|
||||
t.Setenv("SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_REPEAT__INTERVAL", "5m")
|
||||
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
@@ -71,7 +70,6 @@ type provider struct {
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -104,7 +102,6 @@ func NewFactory(
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
@@ -140,7 +137,6 @@ func NewFactory(
|
||||
llmPricingRuleHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -178,7 +174,6 @@ func newProvider(
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -215,7 +210,6 @@ func newProvider(
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
}
|
||||
|
||||
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
|
||||
@@ -340,10 +334,6 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addStatsReporterRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addStatsReporterRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/stats", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.statsHandler.Get),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetStats",
|
||||
Tags: []string{"stats"},
|
||||
Summary: "Get stats",
|
||||
Description: "This endpoint returns the collected stats for the organization",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: map[string]any{},
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -18,8 +18,8 @@ func clearSignozEnv(t *testing.T) {
|
||||
if strings.HasPrefix(kv, prefix) {
|
||||
key := strings.SplitN(kv, "=", 2)[0]
|
||||
orig, _ := os.LookupEnv(key)
|
||||
_ = os.Unsetenv(key)
|
||||
t.Cleanup(func() { _ = os.Setenv(key, orig) })
|
||||
os.Unsetenv(key)
|
||||
t.Cleanup(func() { os.Setenv(key, orig) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,25 +21,18 @@ type base struct {
|
||||
e error
|
||||
// u denotes the url for the documentation (if present) for the error.
|
||||
u string
|
||||
// a denotes any additional error details (if present). Each detail carries an
|
||||
// optional message and any user-facing suggestions closely related to it.
|
||||
a []additional
|
||||
// a denotes any additional error messages (if present).
|
||||
a []string
|
||||
// s contains the stacktrace captured at error creation time.
|
||||
s fmt.Stringer
|
||||
// r is the retry strategy for the error, if applicable.
|
||||
r *retry
|
||||
// suggestions is a list of user-facing suggestions related to the error as a
|
||||
// whole (not tied to a specific detail in a), if present. For example,
|
||||
// "narrow the time range window". For a suggestion tied to a specific detail,
|
||||
// use the suggestions field on additional instead.
|
||||
suggestions []string
|
||||
}
|
||||
|
||||
// additional is a single supplementary error detail: a message plus any
|
||||
// user-facing suggestions (e.g. "did you mean: `x`") closely related to it.
|
||||
type additional struct {
|
||||
message string
|
||||
// suggestions is a list of user-facing suggestions related to the error, if present.
|
||||
// For example, narrow the time range window or typo suggestion
|
||||
suggestions []string
|
||||
// invalidReferences is a list of references that were invalid and contributed to the error, if present.
|
||||
// For example, a typo from user avg(sum), we return invalidRefences: ['sum']
|
||||
invalidReferences []string
|
||||
}
|
||||
|
||||
// Stacktrace returns the stacktrace captured at error creation time, formatted as a string.
|
||||
@@ -54,15 +47,16 @@ func (b *base) Stacktrace() string {
|
||||
// and returns a new base error.
|
||||
func (b *base) WithStacktrace(s string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: rawStacktrace(s),
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: rawStacktrace(s),
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
invalidReferences: b.invalidReferences,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +77,7 @@ func New(t typ, code Code, message string) *base {
|
||||
m: message,
|
||||
e: nil,
|
||||
u: "",
|
||||
a: []additional{},
|
||||
a: []string{},
|
||||
s: newStackTrace(),
|
||||
}
|
||||
}
|
||||
@@ -102,144 +96,127 @@ func Newf(t typ, code Code, format string, args ...any) *base {
|
||||
// Wrapf returns a new error by formatting the error message with the supplied format specifier
|
||||
// and wrapping another error with base.
|
||||
func Wrapf(cause error, t typ, code Code, format string, args ...any) *base {
|
||||
b := &base{
|
||||
return &base{
|
||||
t: t,
|
||||
c: code,
|
||||
m: fmt.Sprintf(format, args...),
|
||||
e: cause,
|
||||
s: newStackTrace(),
|
||||
}
|
||||
|
||||
// Carry the user-facing hints forward from the wrapped cause. Otherwise
|
||||
// wrapping a structured error (e.g. one returned from an UnmarshalJSON) would
|
||||
// silently drop its suggestions / invalid references from the response.
|
||||
// Propagation is transitive: each Wrapf copies from its immediate cause, so
|
||||
// the hints survive arbitrarily deep wrapping as long as it goes through Wrapf.
|
||||
if inner, ok := cause.(*base); ok {
|
||||
b.r = inner.r
|
||||
b.a = inner.a
|
||||
b.suggestions = inner.suggestions
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// Wrap returns a new error by wrapping another error with base.
|
||||
func Wrap(cause error, t typ, code Code, message string) *base {
|
||||
b := &base{
|
||||
return &base{
|
||||
t: t,
|
||||
c: code,
|
||||
m: message,
|
||||
e: cause,
|
||||
s: newStackTrace(),
|
||||
}
|
||||
|
||||
// Carry the user-facing hints forward from the wrapped cause. Otherwise
|
||||
// wrapping a structured error (e.g. one returned from an UnmarshalJSON) would
|
||||
// silently drop its suggestions / invalid references from the response.
|
||||
// Propagation is transitive: each Wrapf copies from its immediate cause, so
|
||||
// the hints survive arbitrarily deep wrapping as long as it goes through Wrapf.
|
||||
if inner, ok := cause.(*base); ok {
|
||||
b.r = inner.r
|
||||
b.a = inner.a
|
||||
b.suggestions = inner.suggestions
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// WithAdditionalf adds an additional error message to the existing error.
|
||||
func WithAdditionalf(cause error, format string, args ...any) *base {
|
||||
if b, ok := cause.(*base); ok {
|
||||
return b.WithAdditional(fmt.Sprintf(format, args...))
|
||||
t, c, m, e, u, a := Unwrapb(cause)
|
||||
var s fmt.Stringer
|
||||
if original, ok := cause.(*base); ok {
|
||||
s = original.s
|
||||
}
|
||||
b := &base{
|
||||
t: t,
|
||||
c: c,
|
||||
m: m,
|
||||
e: e,
|
||||
u: u,
|
||||
a: a,
|
||||
s: s,
|
||||
r: retryOf(cause),
|
||||
suggestions: suggestionsOf(cause),
|
||||
invalidReferences: invalidReferencesOf(cause),
|
||||
}
|
||||
|
||||
t, c, m, e, u, a := Unwrapb(cause)
|
||||
b := &base{t: t, c: c, m: m, e: e, u: u, a: a, s: newStackTrace(), r: retryOf(cause)}
|
||||
return b.WithAdditional(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// WithSuggestiveAdditionalf appends a detail whose message is built from the format
|
||||
// specifier and which carries the given user-facing suggestions closely related to
|
||||
// it, returning a new base error.
|
||||
func WithSuggestiveAdditionalf(cause error, suggestions []string, format string, args ...any) *base {
|
||||
if b, ok := cause.(*base); ok {
|
||||
return b.WithSuggestiveAdditional(fmt.Sprintf(format, args...), suggestions...)
|
||||
}
|
||||
|
||||
t, c, m, e, u, a := Unwrapb(cause)
|
||||
b := &base{t: t, c: c, m: m, e: e, u: u, a: a, s: newStackTrace(), r: retryOf(cause)}
|
||||
return b.WithSuggestiveAdditional(fmt.Sprintf(format, args...), suggestions...)
|
||||
return b.WithAdditional(append(a, fmt.Sprintf(format, args...))...)
|
||||
}
|
||||
|
||||
// WithUrl adds a url to the base error and returns a new base error.
|
||||
func (b *base) WithUrl(u string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
invalidReferences: b.invalidReferences,
|
||||
}
|
||||
}
|
||||
|
||||
// WithAdditional appends one or more message-only details and returns a new base error.
|
||||
func (b *base) WithAdditional(messages ...string) *base {
|
||||
extra := make([]additional, len(messages))
|
||||
for i, m := range messages {
|
||||
extra[i] = additional{message: m}
|
||||
// WithAdditional adds additional messages to the base error and returns a new base error.
|
||||
func (b *base) WithAdditional(a ...string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
invalidReferences: b.invalidReferences,
|
||||
}
|
||||
return b.WithAdditionals(extra...)
|
||||
}
|
||||
|
||||
// WithAdditionals appends the given details and returns a new base error. It is also
|
||||
// the way to re-attach details previously pulled out via Unwrapb.
|
||||
func (b *base) WithAdditionals(additionals ...additional) *base {
|
||||
nb := *b
|
||||
nb.a = append(append([]additional{}, b.a...), additionals...)
|
||||
return &nb
|
||||
}
|
||||
|
||||
// withRetry adds retry metadata to the base error and returns a new base error.
|
||||
func (b *base) withRetry(r retry) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: &r,
|
||||
suggestions: b.suggestions,
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: &r,
|
||||
suggestions: b.suggestions,
|
||||
invalidReferences: b.invalidReferences,
|
||||
}
|
||||
}
|
||||
|
||||
// WithSuggestions replaces the error-wide suggestions and returns a new base error.
|
||||
// These relate to the error as a whole; for a suggestion tied to a specific detail,
|
||||
// use WithSuggestiveAdditional.
|
||||
// WithSuggestions replaces the list of suggestions on the base error.
|
||||
func (b *base) WithSuggestions(suggestions ...string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: suggestions,
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: suggestions,
|
||||
invalidReferences: b.invalidReferences,
|
||||
}
|
||||
}
|
||||
|
||||
// WithSuggestiveAdditional appends a detail carrying a message together with the
|
||||
// user-facing suggestions closely related to it, and returns a new base error.
|
||||
func (b *base) WithSuggestiveAdditional(message string, suggestions ...string) *base {
|
||||
return b.WithAdditionals(additional{message: message, suggestions: suggestions})
|
||||
// WithInvalidReferences replaces the list of invalid references on the base error.
|
||||
func (b *base) WithInvalidReferences(invalidReferences ...string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
c: b.c,
|
||||
m: b.m,
|
||||
e: b.e,
|
||||
u: b.u,
|
||||
a: b.a,
|
||||
s: b.s,
|
||||
r: b.r,
|
||||
suggestions: b.suggestions,
|
||||
invalidReferences: invalidReferences,
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryAfter sets the retry delay on the base error and returns a new base error.
|
||||
@@ -254,13 +231,13 @@ func (b *base) WithRetryAfter(delay time.Duration) *base {
|
||||
// and the error itself.
|
||||
//
|
||||
//nolint:staticcheck // ST1008: intentional return order matching struct field order (TCMEUA)
|
||||
func Unwrapb(cause error) (typ, Code, string, error, string, []additional) {
|
||||
func Unwrapb(cause error) (typ, Code, string, error, string, []string) {
|
||||
base, ok := cause.(*base)
|
||||
if ok {
|
||||
return base.t, base.c, base.m, base.e, base.u, base.a
|
||||
}
|
||||
|
||||
return TypeInternal, CodeUnknown, cause.Error(), cause, "", []additional{}
|
||||
return TypeInternal, CodeUnknown, cause.Error(), cause, "", []string{}
|
||||
}
|
||||
|
||||
// Ast checks if the provided error matches the specified custom error type.
|
||||
@@ -394,3 +371,11 @@ func suggestionsOf(err error) []string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidReferencesOf(err error) []string {
|
||||
base, ok := err.(*base)
|
||||
if ok {
|
||||
return base.invalidReferences
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestUnwrapb(t *testing.T) {
|
||||
assert.Equal(t, "this is a base err", amessage)
|
||||
assert.Equal(t, oerr, aerr)
|
||||
assert.Equal(t, "https://docs", au)
|
||||
assert.Equal(t, []additional{{message: "additional err"}}, aa)
|
||||
assert.Equal(t, []string{"additional err"}, aa)
|
||||
|
||||
atyp, _, _, _, _, _ = Unwrapb(oerr)
|
||||
assert.Equal(t, TypeInternal, atyp)
|
||||
@@ -74,19 +74,6 @@ func TestWithSuggestions(t *testing.T) {
|
||||
assert.Equal(t, []string{"first", "second"}, suggestionsOf(err))
|
||||
}
|
||||
|
||||
func TestWithSuggestiveAdditional(t *testing.T) {
|
||||
// WithSuggestiveAdditional attaches suggestions to a specific detail (in the
|
||||
// errors array), distinct from the error-wide WithSuggestions.
|
||||
err := NewInvalidInputf(MustNewCode("bad_field"), "unknown field %q", "filed").
|
||||
WithSuggestiveAdditional("field `filed` not found", "did you mean: `field`")
|
||||
|
||||
j := AsJSON(err)
|
||||
assert.Equal(t, []responseerroradditional{
|
||||
{Message: "field `filed` not found", Suggestions: []string{"did you mean: `field`"}},
|
||||
}, j.Errors)
|
||||
assert.Nil(t, j.Suggestions, "detail-scoped suggestions must not leak into the error-wide list")
|
||||
}
|
||||
|
||||
func TestWithRetryAfter(t *testing.T) {
|
||||
err := New(TypeInternal, MustNewCode("test_code"), "test error").WithRetryAfter(5 * time.Microsecond)
|
||||
r := retryOf(err)
|
||||
@@ -94,11 +81,24 @@ func TestWithRetryAfter(t *testing.T) {
|
||||
assert.Equal(t, 5, int(r.delay.Microseconds()))
|
||||
}
|
||||
|
||||
func TestWithInvalidReferences(t *testing.T) {
|
||||
// WithInvalidReferences populates the list.
|
||||
err := New(TypeInvalidInput, MustNewCode("bad_ref"), "bad ref").
|
||||
WithInvalidReferences("queries[0]", "queries[1]")
|
||||
assert.Equal(t, []string{"queries[0]", "queries[1]"}, invalidReferencesOf(err))
|
||||
|
||||
// WithInvalidReferences replaces the entire list on each call.
|
||||
err = err.WithInvalidReferences("queries[2]")
|
||||
assert.Equal(t, []string{"queries[2]"}, invalidReferencesOf(err),
|
||||
"WithInvalidReferences must replace the entire list")
|
||||
}
|
||||
|
||||
func TestAsJSONBaseError(t *testing.T) {
|
||||
err := New(TypeInvalidInput, MustNewCode("bad_input"), "field foo is bad").
|
||||
WithUrl("https://docs/bad_input").
|
||||
WithAdditional("hint1", "hint2").
|
||||
WithSuggestions("try this")
|
||||
WithSuggestions("try this").
|
||||
WithInvalidReferences("queries[0]")
|
||||
|
||||
j := AsJSON(err)
|
||||
|
||||
@@ -113,20 +113,7 @@ func TestAsJSONBaseError(t *testing.T) {
|
||||
assert.Nil(t, j.Retry, "bare New(...) should not populate a retry block")
|
||||
|
||||
assert.Equal(t, []string{"try this"}, j.Suggestions)
|
||||
}
|
||||
|
||||
func TestAsJSONWrappedErrorPreservesHints(t *testing.T) {
|
||||
// An inner base carries the user-facing hints (e.g. produced inside an
|
||||
// UnmarshalJSON), then gets re-wrapped (e.g. WrapInvalidInputf). suggestionsOf
|
||||
// must walk the cause chain so the hints still surface.
|
||||
inner := NewInvalidInputf(MustNewCode("bad_kind"), "unknown panel kind %q", "boom").
|
||||
WithSuggestions("valid references: a, b, c")
|
||||
|
||||
wrapped := WrapInvalidInputf(inner, MustNewCode("outer"), "%s", inner.Error())
|
||||
|
||||
j := AsJSON(wrapped)
|
||||
assert.Equal(t, []string{"valid references: a, b, c"}, j.Suggestions,
|
||||
"suggestions on an inner base must survive wrapping")
|
||||
assert.Equal(t, []string{"queries[0]"}, j.InvalidReferences)
|
||||
}
|
||||
|
||||
func TestAsJSONRetryBlock(t *testing.T) {
|
||||
@@ -160,6 +147,7 @@ func TestAsJSONRetryBlock(t *testing.T) {
|
||||
func TestAsJSONOptionalFieldsOmittedWhenEmpty(t *testing.T) {
|
||||
j := AsJSON(New(TypeInternal, MustNewCode("boom"), "boom"))
|
||||
assert.Nil(t, j.Suggestions, "no suggestions set => Suggestions must be nil so json omitempty drops it")
|
||||
assert.Nil(t, j.InvalidReferences, "no invalid references set => InvalidReferences must be nil so json omitempty drops it")
|
||||
}
|
||||
|
||||
func TestWithStacktrace(t *testing.T) {
|
||||
|
||||
@@ -7,13 +7,14 @@ import (
|
||||
)
|
||||
|
||||
type JSON struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Code string `json:"code" required:"true"`
|
||||
Message string `json:"message" required:"true"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Errors []responseerroradditional `json:"errors,omitempty"`
|
||||
Retry *responseretryjson `json:"retry,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Code string `json:"code" required:"true"`
|
||||
Message string `json:"message" required:"true"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Errors []responseerroradditional `json:"errors,omitempty"`
|
||||
Retry *responseretryjson `json:"retry,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
InvalidReferences []string `json:"invalidReferences,omitempty"`
|
||||
}
|
||||
|
||||
type responseretryjson struct {
|
||||
@@ -21,8 +22,7 @@ type responseretryjson struct {
|
||||
}
|
||||
|
||||
type responseerroradditional struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func AsJSON(cause error) *JSON {
|
||||
@@ -31,7 +31,7 @@ func AsJSON(cause error) *JSON {
|
||||
|
||||
rea := make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
rea[k] = responseerroradditional{v}
|
||||
}
|
||||
|
||||
var retry *responseretryjson
|
||||
@@ -40,13 +40,14 @@ func AsJSON(cause error) *JSON {
|
||||
}
|
||||
|
||||
return &JSON{
|
||||
Type: t.String(),
|
||||
Code: c.String(),
|
||||
Message: m,
|
||||
Url: u,
|
||||
Errors: rea,
|
||||
Retry: retry,
|
||||
Suggestions: suggestionsOf(cause),
|
||||
Type: t.String(),
|
||||
Code: c.String(),
|
||||
Message: m,
|
||||
Url: u,
|
||||
Errors: rea,
|
||||
Retry: retry,
|
||||
Suggestions: suggestionsOf(cause),
|
||||
InvalidReferences: invalidReferencesOf(cause),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ func AsURLValues(cause error) url.Values {
|
||||
|
||||
rea := make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
rea[k] = responseerroradditional{v}
|
||||
}
|
||||
|
||||
errors, err := json.Marshal(rea)
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
typoSuggestionThreshold = 0.75
|
||||
// maxValidReferences caps how many valid references are listed so
|
||||
// high-cardinality sets (e.g. telemetry field keys) don't dump the entire
|
||||
// set into the error.
|
||||
maxValidReferences = 20
|
||||
)
|
||||
|
||||
// SuggestionsOnLevenshteinDistance returns a "did you mean" correction (only
|
||||
// when a close match at least typoSuggestionThreshold similar exists) followed
|
||||
// by the valid-references list.
|
||||
func SuggestionsOnLevenshteinDistance(invalidInput string, validInputs []string) []string {
|
||||
suggestions := make([]string, 0, 2)
|
||||
|
||||
if match, ok := ClosestLevenshteinMatch(invalidInput, validInputs); ok {
|
||||
suggestions = append(suggestions, didYouMean(match))
|
||||
}
|
||||
|
||||
if refs := ValidReferences(validInputs...); refs != "" {
|
||||
suggestions = append(suggestions, refs)
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// ClosestLevenshteinMatch returns the candidate most similar to input that is at least
|
||||
// typoSuggestionThreshold similar, or false when nothing is close enough.
|
||||
func ClosestLevenshteinMatch(input string, candidates []string) (string, bool) {
|
||||
var bestMatch string
|
||||
bestSimilarity := 0.0
|
||||
|
||||
for _, candidate := range candidates {
|
||||
sim := similarity(input, candidate)
|
||||
|
||||
if sim > bestSimilarity && sim >= typoSuggestionThreshold {
|
||||
bestSimilarity = sim
|
||||
bestMatch = candidate
|
||||
}
|
||||
}
|
||||
|
||||
if bestSimilarity >= typoSuggestionThreshold {
|
||||
return bestMatch, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SuggestionsFromFunc formats the string produce returns as a one-element
|
||||
// "did you mean: `x`" slice, or nil when it returns the empty string (so callers
|
||||
// with their own matching strategy compose into a suggestions list cleanly).
|
||||
func SuggestionsFromFunc(produce func() string) []string {
|
||||
s := produce()
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{didYouMean(s)}
|
||||
}
|
||||
|
||||
// ValidReferences formats values as "valid references: `a`, `b`", capped at
|
||||
// maxValidReferences with a "(+N more)" suffix. Each value is rendered as its
|
||||
// own string, an Enum() element's StringValue(), or fmt.Sprint as a fallback.
|
||||
// It returns "" when there are no values, so callers don't surface a bare
|
||||
// "valid references: " with nothing after it.
|
||||
func ValidReferences[T any](values ...T) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
refs := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
switch t := any(v).(type) {
|
||||
case string:
|
||||
refs = append(refs, t)
|
||||
case interface{ StringValue() string }:
|
||||
refs = append(refs, t.StringValue())
|
||||
default:
|
||||
refs = append(refs, fmt.Sprint(t))
|
||||
}
|
||||
}
|
||||
|
||||
truncated := 0
|
||||
if len(refs) > maxValidReferences {
|
||||
truncated = len(refs) - maxValidReferences
|
||||
refs = refs[:maxValidReferences]
|
||||
}
|
||||
|
||||
quoted := make([]string, len(refs))
|
||||
for i, r := range refs {
|
||||
quoted[i] = "`" + r + "`"
|
||||
}
|
||||
|
||||
out := "valid references: " + strings.Join(quoted, ", ")
|
||||
if truncated > 0 {
|
||||
out += fmt.Sprintf(" (+%d more)", truncated)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
s1 = strings.ToLower(s1)
|
||||
s2 = strings.ToLower(s2)
|
||||
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
v0 := make([]int, len(s2)+1)
|
||||
v1 := make([]int, len(s2)+1)
|
||||
|
||||
for i := 0; i <= len(s2); i++ {
|
||||
v0[i] = i
|
||||
}
|
||||
|
||||
for i := range len(s1) {
|
||||
v1[0] = i + 1
|
||||
|
||||
for j := range len(s2) {
|
||||
deletionCost := v0[j+1] + 1
|
||||
insertionCost := v1[j] + 1
|
||||
|
||||
var substitutionCost int
|
||||
if s1[i] == s2[j] {
|
||||
substitutionCost = v0[j]
|
||||
} else {
|
||||
substitutionCost = v0[j] + 1
|
||||
}
|
||||
|
||||
v1[j+1] = min(deletionCost, insertionCost, substitutionCost)
|
||||
}
|
||||
|
||||
for j := 0; j <= len(s2); j++ {
|
||||
v0[j] = v1[j]
|
||||
}
|
||||
}
|
||||
|
||||
return v1[len(s2)]
|
||||
}
|
||||
|
||||
func similarity(s1, s2 string) float64 {
|
||||
maxLen := max(len(s1), len(s2))
|
||||
if maxLen == 0 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
distance := levenshteinDistance(s1, s2)
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
// didYouMean formats a correction as "did you mean: `x`".
|
||||
func didYouMean(match string) string {
|
||||
return "did you mean: `" + match + "`"
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidReferences(t *testing.T) {
|
||||
// An empty set returns "" so callers don't surface a bare "valid references: ".
|
||||
assert.Equal(t, "", ValidReferences[string]())
|
||||
|
||||
assert.Equal(t, "valid references: `a`, `b`", ValidReferences("a", "b"))
|
||||
}
|
||||
|
||||
func TestSuggestionsOnLevenshteinDistance(t *testing.T) {
|
||||
// No valid inputs => no suggestions at all (no bare "valid references: ").
|
||||
assert.Empty(t, SuggestionsOnLevenshteinDistance("foo", nil))
|
||||
|
||||
// Close match => did-you-mean plus the valid-references list.
|
||||
assert.Equal(t,
|
||||
[]string{"did you mean: `name`", "valid references: `name`, `color`"},
|
||||
SuggestionsOnLevenshteinDistance("nam", []string{"name", "color"}),
|
||||
)
|
||||
|
||||
// No close match => valid-references list only.
|
||||
assert.Equal(t,
|
||||
[]string{"valid references: `name`, `color`"},
|
||||
SuggestionsOnLevenshteinDistance("zzzzz", []string{"name", "color"}),
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,11 @@ func (handler *handler) GetFeatures(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ var (
|
||||
type bindBodyOptions struct {
|
||||
DisallowUnknownFields bool
|
||||
UseNumber bool
|
||||
UnknownFieldContext string
|
||||
}
|
||||
|
||||
type BindBodyOption func(*bindBodyOptions)
|
||||
@@ -31,12 +30,6 @@ func WithDisallowUnknownFields(disallowUnknownFields bool) BindBodyOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithUnknownFieldContext(context string) BindBodyOption {
|
||||
return func(options *bindBodyOptions) {
|
||||
options.UnknownFieldContext = context
|
||||
}
|
||||
}
|
||||
|
||||
func WithUseNumber(useNumber bool) BindBodyOption {
|
||||
return func(options *bindBodyOptions) {
|
||||
options.UseNumber = useNumber
|
||||
|
||||
@@ -3,8 +3,6 @@ package binding
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
@@ -61,70 +59,8 @@ func (b *jsonBinding) BindBody(body io.Reader, obj any, opts ...BindBodyOption)
|
||||
WithAdditional("value of type '" + unmarshalTypeError.Value + "' was received, try sending '" + unmarshalTypeError.Type.String() + "' instead?")
|
||||
}
|
||||
|
||||
// DisallowUnknownFields surfaces a bare `json: unknown field "x"`; turn it
|
||||
// into a structured invalid-input error with did-you-mean/valid-reference
|
||||
// suggestions drawn from obj's own JSON field names. Gated on the strict
|
||||
// flag so an already-structured "unknown field" error bubbling up from a
|
||||
// nested UnmarshalJSON is passed through unchanged, not re-wrapped here with
|
||||
// the wrong (outer) field set.
|
||||
if bindBodyOptions.DisallowUnknownFields && strings.Contains(err.Error(), "unknown field") {
|
||||
if field := extractUnknownField(err.Error()); field != "" {
|
||||
message := "unknown field %q"
|
||||
if bindBodyOptions.UnknownFieldContext != "" {
|
||||
message = "unknown field %q in " + bindBodyOptions.UnknownFieldContext
|
||||
}
|
||||
|
||||
return errors.
|
||||
NewInvalidInputf(errors.CodeInvalidInput, message, field).
|
||||
WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field, JSONFieldNames(obj))...)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// JSONFieldNames returns the JSON field names of a struct (or pointer to one),
|
||||
// skipping fields tagged "-" or without a json tag.
|
||||
func JSONFieldNames(v any) []string {
|
||||
var fields []string
|
||||
|
||||
t := reflect.TypeOf(v)
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
if t.Kind() != reflect.Struct {
|
||||
return fields
|
||||
}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := strings.Split(jsonTag, ",")[0]
|
||||
if fieldName != "" {
|
||||
fields = append(fields, fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// extractUnknownField pulls fieldname out of a `json: unknown field "fieldname"`
|
||||
// decoder message, or returns "" when the message has no quoted field.
|
||||
func extractUnknownField(errMsg string) string {
|
||||
parts := strings.Split(errMsg, `"`)
|
||||
|
||||
if len(parts) >= 2 {
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -58,89 +58,11 @@ func TestJSONBinding_BindBodyErrors(t *testing.T) {
|
||||
err := JSON.BindBody(strings.NewReader(testCase.body), testCase.obj, testCase.opts...)
|
||||
assert.Error(t, err)
|
||||
|
||||
typ, c, m, _, _, _ := errors.Unwrapb(err)
|
||||
typ, c, m, _, _, a := errors.Unwrapb(err)
|
||||
assert.Equal(t, errors.TypeInvalidInput, typ)
|
||||
assert.Equal(t, testCase.code, c)
|
||||
assert.Equal(t, testCase.message, m)
|
||||
|
||||
messages := []string{}
|
||||
for _, additional := range errors.AsJSON(err).Errors {
|
||||
messages = append(messages, additional.Message)
|
||||
}
|
||||
assert.ElementsMatch(t, testCase.a, messages)
|
||||
assert.ElementsMatch(t, testCase.a, a)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type widget struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func TestJSONBinding_BindBody_UnknownFieldSuggestions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
opts []BindBodyOption
|
||||
message string
|
||||
suggestions []string
|
||||
}{
|
||||
{
|
||||
name: "NoNearMatch",
|
||||
body: `{"shape":"round"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true)},
|
||||
message: `unknown field "shape"`,
|
||||
suggestions: []string{"valid references: `name`, `color`"},
|
||||
},
|
||||
{
|
||||
name: "WithContext",
|
||||
body: `{"shape":"round"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true), WithUnknownFieldContext("widget spec")},
|
||||
message: `unknown field "shape" in widget spec`,
|
||||
suggestions: []string{"valid references: `name`, `color`"},
|
||||
},
|
||||
{
|
||||
name: "NearMatch",
|
||||
body: `{"nam":"x"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true)},
|
||||
message: `unknown field "nam"`,
|
||||
suggestions: []string{"did you mean: `name`", "valid references: `name`, `color`"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := JSON.BindBody(strings.NewReader(testCase.body), &widget{}, testCase.opts...)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, c, m, _, _, _ := errors.Unwrapb(err)
|
||||
|
||||
if testCase.message != "" {
|
||||
assert.Equal(t, errors.CodeInvalidInput, c)
|
||||
assert.Equal(t, testCase.message, m)
|
||||
}
|
||||
|
||||
assert.Equal(t, testCase.suggestions, errors.AsJSON(err).Suggestions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type structuredUnknownField struct{}
|
||||
|
||||
func (*structuredUnknownField) UnmarshalJSON([]byte) error {
|
||||
return errors.
|
||||
NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q in inner spec", "foo").
|
||||
WithSuggestions("did you mean: `bar`")
|
||||
}
|
||||
|
||||
// A non-strict BindBody must pass through an already-structured "unknown field"
|
||||
// error returned by a nested UnmarshalJSON, not re-wrap it with the outer field set.
|
||||
func TestJSONBinding_BindBody_PassesThroughStructuredUnknownField(t *testing.T) {
|
||||
err := JSON.BindBody(strings.NewReader(`{}`), &structuredUnknownField{})
|
||||
assert.Error(t, err)
|
||||
|
||||
_, c, m, _, _, _ := errors.Unwrapb(err)
|
||||
assert.Equal(t, errors.CodeInvalidInput, c)
|
||||
assert.Equal(t, `unknown field "foo" in inner spec`, m)
|
||||
assert.Equal(t, []string{"did you mean: `bar`"}, errors.AsJSON(err).Suggestions)
|
||||
}
|
||||
|
||||
@@ -113,11 +113,9 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
openapi.WithHTTPStatus(handler.openAPIDef.SuccessStatusCode),
|
||||
)
|
||||
} else {
|
||||
// No response body (e.g. 204 No Content): omit the content type so the
|
||||
// spec doesn't declare a body for a bodyless response, which would make
|
||||
// clients try to decode an empty payload.
|
||||
opCtx.AddRespStructure(
|
||||
nil,
|
||||
openapi.WithContentType(handler.openAPIDef.ResponseContentType),
|
||||
openapi.WithHTTPStatus(handler.openAPIDef.SuccessStatusCode),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (m *module) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, error) {
|
||||
stats := make(map[string]any)
|
||||
|
||||
metadataTable := fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName)
|
||||
var (
|
||||
systemMetricCount uint64
|
||||
k8sMetricCount uint64
|
||||
)
|
||||
query := fmt.Sprintf(
|
||||
"SELECT (SELECT count() FROM (SELECT 1 FROM %s WHERE metric_name LIKE 'system.%%' LIMIT 1)), (SELECT count() FROM (SELECT 1 FROM %s WHERE metric_name LIKE 'k8s.%%' LIMIT 1))",
|
||||
metadataTable, metadataTable,
|
||||
)
|
||||
if err := m.telemetryStore.ClickhouseDB().QueryRow(ctx, query).Scan(&systemMetricCount, &k8sMetricCount); err == nil {
|
||||
stats["telemetry.metrics.system.exists"] = systemMetricCount > 0
|
||||
stats["telemetry.metrics.k8s.exists"] = k8sMetricCount > 0
|
||||
} else {
|
||||
m.logger.DebugContext(ctx, "failed to collect metrics namespace existence stats", errors.Attr(err))
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -23,7 +22,6 @@ type Handler interface {
|
||||
}
|
||||
|
||||
type Module interface {
|
||||
statsreporter.StatsCollector
|
||||
ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error)
|
||||
ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error)
|
||||
ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error)
|
||||
|
||||
@@ -54,7 +54,7 @@ func (h *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rules, total, err := h.module.List(ctx, orgID, q.Offset, q.Limit, q.Search, q.IsOverride)
|
||||
rules, total, err := h.module.List(ctx, orgID, q.Offset, q.Limit)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -21,8 +21,8 @@ func NewModule(store llmpricingruletypes.Store) llmpricingrule.Module {
|
||||
return &module{store: store}
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
return module.store.List(ctx, orgID, offset, limit, search, isOverride)
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
return module.store.List(ctx, orgID, offset, limit)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
@@ -108,7 +108,7 @@ func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []
|
||||
}
|
||||
|
||||
func (module *module) getEnabledRules(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
|
||||
rules, _, err := module.List(ctx, orgID, 0, 10000, "", nil)
|
||||
rules, _, err := module.List(ctx, orgID, 0, 10000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -17,25 +17,14 @@ func NewStore(sqlstore sqlstore.SQLStore) llmpricingruletypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
rules := make([]*llmpricingruletypes.LLMPricingRule, 0)
|
||||
|
||||
query := store.sqlstore.
|
||||
count, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&rules).
|
||||
Where("org_id = ?", orgID)
|
||||
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("(LOWER(model) LIKE LOWER(?) OR LOWER(provider) LIKE LOWER(?))", like, like)
|
||||
}
|
||||
|
||||
if isOverride != nil {
|
||||
query = query.Where("is_override = ?", *isOverride)
|
||||
}
|
||||
|
||||
count, err := query.
|
||||
Where("org_id = ?", orgID).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
|
||||
@@ -13,7 +13,7 @@ type Module interface {
|
||||
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
|
||||
agentConf.AgentFeature
|
||||
|
||||
List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error)
|
||||
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error)
|
||||
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
|
||||
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)
|
||||
Delete(ctx context.Context, orgID, id valuer.UUID) error
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
@@ -46,7 +45,7 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
var queryRangeRequest qbtypes.QueryRangeRequest
|
||||
if err := binding.JSON.BindBody(req.Body, &queryRangeRequest); err != nil {
|
||||
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
@@ -187,7 +186,7 @@ func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request
|
||||
func (handler *handler) ReplaceVariables(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
var queryRangeRequest qbtypes.QueryRangeRequest
|
||||
if err := binding.JSON.BindBody(req.Body, &queryRangeRequest); err != nil {
|
||||
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (q *querier) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, error) {
|
||||
stats := make(map[string]any)
|
||||
|
||||
tracesTable := fmt.Sprintf("%s.%s", telemetrytraces.DBName, telemetrytraces.SpanIndexV3TableName)
|
||||
logsTable := fmt.Sprintf("%s.%s", telemetrylogs.DBName, telemetrylogs.LogsV2TableName)
|
||||
metricsTable := fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4TableName)
|
||||
|
||||
var (
|
||||
traces uint64
|
||||
tracesLastSeenAt time.Time
|
||||
)
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), max(timestamp) FROM %s", tracesTable)).Scan(&traces, &tracesLastSeenAt); err == nil {
|
||||
stats["telemetry.traces.count"] = traces
|
||||
if tracesLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.traces.last_observed.time"] = tracesLastSeenAt.UTC()
|
||||
stats["telemetry.traces.last_observed.time_unix"] = tracesLastSeenAt.Unix()
|
||||
}
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect traces stats", errors.Attr(err))
|
||||
}
|
||||
|
||||
var (
|
||||
logs uint64
|
||||
logsLastSeenAt time.Time
|
||||
)
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), fromUnixTimestamp64Nano(max(timestamp)) FROM %s", logsTable)).Scan(&logs, &logsLastSeenAt); err == nil {
|
||||
stats["telemetry.logs.count"] = logs
|
||||
if logsLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.logs.last_observed.time"] = logsLastSeenAt.UTC()
|
||||
stats["telemetry.logs.last_observed.time_unix"] = logsLastSeenAt.Unix()
|
||||
}
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect logs stats", errors.Attr(err))
|
||||
}
|
||||
|
||||
var (
|
||||
metrics uint64
|
||||
metricsLastSeenAt time.Time
|
||||
)
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), toDateTime(max(unix_milli) / 1000) FROM %s", metricsTable)).Scan(&metrics, &metricsLastSeenAt); err == nil {
|
||||
stats["telemetry.metrics.count"] = metrics
|
||||
if metricsLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.metrics.last_observed.time"] = metricsLastSeenAt.UTC()
|
||||
stats["telemetry.metrics.last_observed.time_unix"] = metricsLastSeenAt.Unix()
|
||||
}
|
||||
} else {
|
||||
q.logger.DebugContext(ctx, "failed to collect metrics stats", errors.Attr(err))
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
type Querier interface {
|
||||
QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error)
|
||||
QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream)
|
||||
statsreporter.StatsCollector
|
||||
}
|
||||
|
||||
// BucketCache is the interface for bucket-based caching.
|
||||
|
||||
@@ -744,32 +744,6 @@ func (m *Manager) TriggeredAlerts() []*ruletypes.NamedAlert {
|
||||
return namedAlerts
|
||||
}
|
||||
|
||||
type AlertStats struct {
|
||||
FiringRules int64
|
||||
LastFiredAt time.Time
|
||||
}
|
||||
|
||||
func (m *Manager) AlertStats(ctx context.Context) AlertStats {
|
||||
m.mtx.RLock()
|
||||
defer m.mtx.RUnlock()
|
||||
|
||||
// TODO(therealpandey): scope these stats per org; rules for all orgs are aggregated here.
|
||||
stats := AlertStats{}
|
||||
for _, r := range m.rules {
|
||||
if r.State() == ruletypes.StateFiring {
|
||||
stats.FiringRules++
|
||||
}
|
||||
|
||||
for _, alert := range r.ActiveAlerts() {
|
||||
if alert.FiredAt.After(stats.LastFiredAt) {
|
||||
stats.LastFiredAt = alert.FiredAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// NotifyFunc sends notifications about a set of alerts generated by the given expression.
|
||||
type NotifyFunc func(ctx context.Context, orgID string, alerts ...*ruletypes.Alert)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func CollisionHandledFinalExpr(
|
||||
|
||||
if requiredDataType != telemetrytypes.FieldDataTypeString &&
|
||||
requiredDataType != telemetrytypes.FieldDataTypeFloat64 {
|
||||
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported data type %s", requiredDataType)
|
||||
return "", nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "unsupported data type %s", requiredDataType)
|
||||
}
|
||||
|
||||
var dummyValue any
|
||||
@@ -81,8 +81,14 @@ func CollisionHandledFinalExpr(
|
||||
// - it is not a static field
|
||||
// - the next best thing to do is see if there is a typo
|
||||
// and suggest a correction
|
||||
wrappedErr := errors.WithSuggestiveAdditionalf(fieldForErr, errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys)), "field `%s` not found", field.Name)
|
||||
return "", nil, wrappedErr
|
||||
correction, found := telemetrytypes.SuggestCorrection(field.Name, maps.Keys(keys))
|
||||
if found {
|
||||
// we found a close match, in the error message send the suggestion
|
||||
return "", nil, errors.WithAdditionalf(fieldForErr, "%s", correction)
|
||||
} else {
|
||||
// not even a close match, return an error
|
||||
return "", nil, errors.WithAdditionalf(fieldForErr, "field `%s` not found", field.Name)
|
||||
}
|
||||
} else {
|
||||
for _, key := range keysForField {
|
||||
err := addCondition(key)
|
||||
|
||||
@@ -294,30 +294,24 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
validKeys = append(validKeys, k)
|
||||
}
|
||||
sort.Strings(validKeys)
|
||||
// Each suggestion is a self-describing string prefixed with either
|
||||
// "did you mean: " (the full corrected expression) or "valid references: "
|
||||
// (the set of valid references).
|
||||
var suggestions []string
|
||||
additional := []string{"Valid references are: [" + strings.Join(validKeys, ", ") + "]"}
|
||||
if len(v.invalid) == 1 {
|
||||
inv := v.invalid[0]
|
||||
suggestions = errors.SuggestionsFromFunc(func() string {
|
||||
match, ok := errors.ClosestLevenshteinMatch(inv, validKeys)
|
||||
if !ok || strings.Contains(original, inv+"(") || strings.Contains(match, "(") {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(original, inv, match)
|
||||
})
|
||||
// Only suggest for plain identifier typos, not for unresolved function
|
||||
// calls: a function call will appear as "name(" in the expression, and
|
||||
// the closest valid key may itself contain "(" (e.g. "sum(a)"), making
|
||||
// a simple string substitution produce a corrupt expression.
|
||||
isFuncCall := strings.Contains(original, inv+"(")
|
||||
if match, dist := closestMatch(inv, validKeys); !isFuncCall && !strings.Contains(match, "(") && dist <= 3 {
|
||||
corrected := strings.ReplaceAll(original, inv, match)
|
||||
additional = append(additional, "Suggestion: `"+corrected+"`")
|
||||
}
|
||||
}
|
||||
|
||||
suggestions = append(suggestions, errors.ValidReferences(validKeys...))
|
||||
havingErr := errors.NewInvalidInputf(
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"Invalid references in `Having` expression: [%s]",
|
||||
strings.Join(v.invalid, ", "),
|
||||
).WithAdditional(
|
||||
"Valid references are: [" + strings.Join(validKeys, ", ") + "]",
|
||||
).WithSuggestions(suggestions...)
|
||||
return "", havingErr
|
||||
).WithAdditional(additional...)
|
||||
}
|
||||
|
||||
// Layer 3 – ANTLR syntax errors. We parse the original expression, so error messages
|
||||
@@ -330,22 +324,21 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
}
|
||||
havingErr := errors.NewInvalidInputf(
|
||||
detail := strings.Join(msgs, "; ")
|
||||
if detail == "" {
|
||||
detail = "check the expression syntax"
|
||||
}
|
||||
additional := []string{detail}
|
||||
// For single-error expressions, try to produce an actionable suggestion.
|
||||
if len(allSyntaxErrors) == 1 {
|
||||
if s := havingSuggestion(allSyntaxErrors[0], original); s != "" {
|
||||
additional = append(additional, "Suggestion: `"+s+"`")
|
||||
}
|
||||
}
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"Syntax error in `Having` expression",
|
||||
)
|
||||
|
||||
// A single syntax error can carry an actionable suggestion on the same detail;
|
||||
// multiple errors are surfaced as one additional detail each. If the parser
|
||||
// produced no message (rare), the top-level message stands on its own.
|
||||
if len(allSyntaxErrors) == 1 && len(msgs) == 1 {
|
||||
suggestions := errors.SuggestionsFromFunc(func() string {
|
||||
return havingSuggestion(allSyntaxErrors[0], original)
|
||||
})
|
||||
|
||||
return "", havingErr.WithSuggestiveAdditional(msgs[0], suggestions...)
|
||||
}
|
||||
return "", havingErr.WithAdditional(msgs...)
|
||||
).WithAdditional(additional...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -455,6 +448,42 @@ func hasUnclosedBracket(s string) bool {
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// closestMatch returns the element of candidates with the smallest Levenshtein
|
||||
// distance to query, along with that distance.
|
||||
func closestMatch(query string, candidates []string) (string, int) {
|
||||
best, bestDist := "", -1
|
||||
for _, c := range candidates {
|
||||
if d := levenshtein(query, c); bestDist < 0 || d < bestDist {
|
||||
best, bestDist = c, d
|
||||
}
|
||||
}
|
||||
return best, bestDist
|
||||
}
|
||||
|
||||
// levenshtein computes the edit distance between a and b.
|
||||
func levenshtein(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
la, lb := len(ra), len(rb)
|
||||
row := make([]int, lb+1)
|
||||
for j := range row {
|
||||
row[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
prev := row[0]
|
||||
row[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
tmp := row[j]
|
||||
if ra[i-1] == rb[j-1] {
|
||||
row[j] = prev
|
||||
} else {
|
||||
row[j] = 1 + min(prev, min(row[j], row[j-1]))
|
||||
}
|
||||
prev = tmp
|
||||
}
|
||||
}
|
||||
return row[lb]
|
||||
}
|
||||
|
||||
// endsWithComparisonOp reports whether s ends with a comparison operator token
|
||||
// (longer operators are checked first to avoid ">=" being matched by ">").
|
||||
func endsWithComparisonOp(s string) bool {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user