Compare commits

..

104 Commits

Author SHA1 Message Date
Naman Verma
301d0103b0 Merge branch 'nv/delete-v2-dashboard' into nv/patch-dashboard 2026-05-05 11:59:12 +05:30
Naman Verma
dc99772ee4 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/delete-v2-dashboard 2026-05-05 11:58:45 +05:30
Naman Verma
80849ebfeb Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-05 11:58:22 +05:30
Naman Verma
2c0c7240a4 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-05 11:56:39 +05:30
Naman Verma
28cb0a8be7 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-05 11:54:54 +05:30
Naman Verma
54832cad34 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-05 11:54:38 +05:30
Naman Verma
a45178d709 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-05 11:54:21 +05:30
Naman Verma
c4224ecf72 Merge branch 'main' into nv/dashboardv2 2026-05-05 11:53:56 +05:30
Naman Verma
14927c89d3 feat: patch dashboard api 2026-05-05 09:22:25 +05:30
Naman Verma
55487dde3a Merge branch 'nv/other-dashboard-v2-update-methods' into nv/delete-v2-dashboard 2026-05-04 19:27:40 +05:30
Naman Verma
fc5717af51 Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-04 19:27:26 +05:30
Naman Verma
8bf650192e Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-04 19:27:14 +05:30
Naman Verma
f8fb7e5f8d Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-04 19:27:02 +05:30
Naman Verma
ff578f7d92 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-04 19:26:49 +05:30
Naman Verma
cd630b1152 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-04 19:26:36 +05:30
Naman Verma
bd0842ac17 fix: query-less panels not allowed 2026-05-04 19:25:49 +05:30
Naman Verma
b3e3dd13b4 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/delete-v2-dashboard 2026-05-04 17:48:42 +05:30
Naman Verma
710d5531f3 Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-04 17:45:15 +05:30
Naman Verma
e37e427079 fix: merge fixes 2026-05-04 17:40:46 +05:30
Naman Verma
1e99ab4659 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-04 17:40:26 +05:30
Naman Verma
3353cda021 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-04 17:35:33 +05:30
Naman Verma
f5a71037bf Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-04 17:33:03 +05:30
Naman Verma
97b85c386a fix: no v2 package and its consequences 2026-05-04 17:27:58 +05:30
Naman Verma
00bdf50c1c fix: no v2 package and its consequences 2026-05-04 17:26:12 +05:30
Naman Verma
5dec4ec580 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-04 17:18:39 +05:30
Naman Verma
325767c240 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-04 17:17:32 +05:30
Naman Verma
5fed2a4585 chore: no v2 subpackage 2026-05-04 17:16:39 +05:30
Naman Verma
664337ae0f Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-04 16:19:29 +05:30
Naman Verma
a0ea276681 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-04 16:18:03 +05:30
Naman Verma
2dc8699f08 fix: wrap errors 2026-05-04 14:55:38 +05:30
Naman Verma
ed81ed8ab5 fix: no need for copying textboxvariablespec 2026-05-04 14:44:42 +05:30
Naman Verma
48c9da19df fix: return 500 err if spec is nil for composite kind w/ code comment 2026-05-04 14:34:16 +05:30
Naman Verma
eb9663d518 fix: remove extra (un)marshal cycle 2026-05-04 14:18:37 +05:30
Naman Verma
a56a862338 fix: add allowed values in err messages 2026-05-04 14:16:22 +05:30
Naman Verma
021f33f65e Merge branch 'main' into nv/dashboardv2 2026-05-04 12:52:31 +05:30
Naman Verma
ca96c71146 feat: delete dashboard v2 API and hard delete cron job 2026-05-03 15:01:43 +05:30
Naman Verma
de2909d1d1 feat: lock, unlock, create public, update public v2 dashboard APIs 2026-05-03 14:38:24 +05:30
Naman Verma
f311fcabf7 feat: v2 dashboard update API 2026-04-29 18:39:48 +05:30
Naman Verma
a37c07f881 feat: v2 dashboard GET API 2026-04-29 15:12:47 +05:30
Naman Verma
4d9386f418 fix: merge conflicts 2026-04-29 14:36:39 +05:30
Naman Verma
737473521d Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-04-29 14:33:25 +05:30
Naman Verma
1863db8ba8 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-29 14:32:14 +05:30
Naman Verma
661af09a13 Merge branch 'main' into nv/dashboardv2 2026-04-29 14:31:59 +05:30
Naman Verma
6024fa2b91 fix: remove extra spec from builder query marshalling 2026-04-29 14:31:16 +05:30
Naman Verma
8996a96387 chore: use existing mapper 2026-04-29 14:09:34 +05:30
Naman Verma
d6db5c2aab test: integration test fixes 2026-04-29 12:56:14 +05:30
Naman Verma
709590ea1b test: integration tests for create API 2026-04-29 12:23:12 +05:30
Naman Verma
1add46b4c5 fix: module should also validate postable dashboard 2026-04-28 20:05:38 +05:30
Naman Verma
8401261e20 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-04-28 20:04:33 +05:30
Naman Verma
0ff34a7274 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 20:04:08 +05:30
Naman Verma
44e3bd9608 chore: separate method for validation 2026-04-28 20:03:48 +05:30
Naman Verma
c3944d779e fix: more dashboard request validations 2026-04-28 19:59:11 +05:30
Naman Verma
f5ec783a53 fix: go lint fix 2026-04-28 19:33:28 +05:30
Naman Verma
35b729c425 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-04-28 19:30:42 +05:30
Naman Verma
4f43c3d803 fix: use existing tag's casing if new tag is a prefix of an existing tag 2026-04-28 19:30:07 +05:30
Naman Verma
5dbde6c64d fix: only return name of a tag in dashboard response 2026-04-28 19:13:03 +05:30
Naman Verma
fb6fdd54ec feat: v2 create dashboard API 2026-04-28 15:05:29 +05:30
Naman Verma
64b8ba62da Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 15:04:11 +05:30
Naman Verma
7c66df408b Merge branch 'main' into nv/dashboardv2 2026-04-28 15:04:03 +05:30
Naman Verma
54049de391 chore: follow proper unmarshal json method structure 2026-04-28 15:02:49 +05:30
Naman Verma
a82f4237c8 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 09:52:23 +05:30
Naman Verma
89606b6238 Merge branch 'main' into nv/dashboardv2 2026-04-28 09:52:13 +05:30
Naman Verma
db5ce958eb Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 09:49:01 +05:30
Naman Verma
c8d3a9a54b feat: enum for entity type that other modules can register 2026-04-28 09:47:24 +05:30
Naman Verma
637870b1fc feat: define tags module for v2 dashboard creation 2026-04-27 22:14:47 +05:30
Naman Verma
d46a7e24c9 Merge branch 'main' into nv/dashboardv2 2026-04-27 22:12:12 +05:30
Naman Verma
2a451e1c31 test: test for drift detection mechanics 2026-04-27 18:57:41 +05:30
Naman Verma
60b6d1d890 chore: better method name extractKindAndSpec 2026-04-27 18:42:31 +05:30
Naman Verma
36f755b232 chore: cleanup comments 2026-04-27 18:39:41 +05:30
Naman Verma
c1b3e3683a chore: code movement 2026-04-27 18:37:57 +05:30
Naman Verma
4c68544b1a chore: go lint fix (godot) 2026-04-27 18:37:05 +05:30
Naman Verma
90d9ab95f9 chore: code movement 2026-04-27 18:35:18 +05:30
Naman Verma
065e712e0c chore: code movement 2026-04-27 18:33:48 +05:30
Naman Verma
50db309ecd chore: code movement 2026-04-27 18:32:41 +05:30
Naman Verma
261bc552b0 chore: cleanup testing code 2026-04-27 18:24:52 +05:30
Naman Verma
bab720e98b Merge branch 'main' into nv/dashboardv2 2026-04-27 18:21:09 +05:30
Naman Verma
71fef6636b chore: better method name 2026-04-27 18:18:14 +05:30
Naman Verma
fc3cdecbbb chore: cleaner comment 2026-04-27 18:15:21 +05:30
Naman Verma
860fcfa641 chore: cleaner comment 2026-04-27 18:14:27 +05:30
Naman Verma
a090e3a4aa chore: cleaner comment 2026-04-27 18:14:02 +05:30
Naman Verma
6cf73e2ade chore: better comment to explain what restrictKindToLiteral does 2026-04-27 18:13:34 +05:30
Naman Verma
bbcb6a45d6 chore: renames and code rearrangement 2026-04-27 17:53:54 +05:30
Naman Verma
d13934febc fix: remove textbox plugin from openapi spec 2026-04-27 17:29:36 +05:30
Naman Verma
d5a7b7523d fix: strict decode variable spec as well 2026-04-27 17:27:51 +05:30
Naman Verma
5b8984f131 Merge branch 'main' into nv/dashboardv2 2026-04-27 17:18:44 +05:30
Naman Verma
6ddc5f1f12 chore: better error messages 2026-04-27 17:18:11 +05:30
Naman Verma
055968bfad fix: dot at the end of a comment 2026-04-27 17:07:58 +05:30
Naman Verma
1bf0f38ed9 fix: js lint errors 2026-04-27 17:07:38 +05:30
Naman Verma
842125e20a chore: too many comments 2026-04-27 16:50:41 +05:30
Naman Verma
6dab35caf8 chore: better file name 2026-04-27 16:43:42 +05:30
Naman Verma
047e9e2001 chore: better file names 2026-04-27 16:42:31 +05:30
Naman Verma
45eaa7db58 test: add tests for spec wrappers 2026-04-27 16:36:27 +05:30
Naman Verma
8a3d894eba chore: comment cleanup 2026-04-27 16:32:29 +05:30
Naman Verma
5239060b53 chore: move plugin maps to correct file 2026-04-27 16:30:33 +05:30
Naman Verma
42c6f507ac test: more descriptive test file name 2026-04-27 15:42:54 +05:30
Naman Verma
1b695a0b80 chore: separate file for perses replicas 2026-04-27 15:42:21 +05:30
Naman Verma
438cfab155 chore: comment clean up 2026-04-27 15:39:46 +05:30
Naman Verma
69f7617e01 Merge branch 'main' into nv/dashboardv2 2026-04-27 15:36:58 +05:30
Naman Verma
4420a7e1fc test: much bigger json for data column 2026-04-24 22:16:03 +05:30
Naman Verma
b4bc68c5c5 test: data column in perf tests should match real data 2026-04-24 17:17:37 +05:30
Naman Verma
eb9eb317cc test: perf test script for both sql flavours 2026-04-23 17:14:33 +05:30
Naman Verma
0b1eb16a42 test: fixes in dashboard perf testing data generator 2026-04-23 15:42:58 +05:30
Naman Verma
05a4d12183 test: script to generate test dashboard data in a sql db 2026-04-23 14:19:58 +05:30
Naman Verma
bbaf64c4f0 feat: openapi spec generation 2026-04-21 13:41:06 +05:30
105 changed files with 6740 additions and 3001 deletions

View File

@@ -27,6 +27,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
@@ -100,8 +101,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), store, settings, analytics, orgGetter, queryParser, tagModule)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()

View File

@@ -42,6 +42,7 @@ import (
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
@@ -144,8 +145,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, dashboardModule), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), store, settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)

View File

@@ -1254,6 +1254,18 @@ components:
required:
- config
type: object
CommonDisplay:
properties:
description:
type: string
name:
type: string
type: object
CommonJSONRef:
properties:
$ref:
type: string
type: object
ConfigAuthorization:
properties:
credentials:
@@ -2205,6 +2217,138 @@ components:
to_user:
type: string
type: object
DashboardGridItem:
properties:
content:
$ref: '#/components/schemas/CommonJSONRef'
height:
type: integer
width:
type: integer
x:
type: integer
"y":
type: integer
type: object
DashboardGridLayoutCollapse:
properties:
open:
type: boolean
type: object
DashboardGridLayoutDisplay:
properties:
collapse:
$ref: '#/components/schemas/DashboardGridLayoutCollapse'
title:
type: string
type: object
DashboardGridLayoutSpec:
properties:
display:
$ref: '#/components/schemas/DashboardGridLayoutDisplay'
items:
items:
$ref: '#/components/schemas/DashboardGridItem'
nullable: true
type: array
repeatVariable:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/VariableDisplay'
name:
type: string
value:
type: string
type: object
DashboardtypesAxes:
properties:
isLogScale:
type: boolean
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesBarChartPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesAxes'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBarChartVisualization'
type: object
DashboardtypesBarChartVisualization:
properties:
fillSpans:
type: boolean
stackedBarChart:
type: boolean
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesBasicVisualization:
properties:
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesBuilderQuerySpec:
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
DashboardtypesComparisonOperator:
enum:
- '>'
- <
- '>='
- <=
- =
- above
- below
- above_or_equal
- below_or_equal
- equal
- not_equal
type: string
DashboardtypesComparisonThreshold:
properties:
color:
type: string
format:
$ref: '#/components/schemas/DashboardtypesThresholdFormat'
operator:
$ref: '#/components/schemas/DashboardtypesComparisonOperator'
unit:
type: string
value:
format: double
type: number
required:
- value
- color
type: object
DashboardtypesCustomVariableSpec:
properties:
customValue:
type: string
required:
- customValue
type: object
DashboardtypesDashboard:
properties:
createdAt:
@@ -2226,6 +2370,126 @@ components:
updatedBy:
type: string
type: object
DashboardtypesDashboardData:
properties:
datasources:
additionalProperties:
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
type: object
display:
$ref: '#/components/schemas/CommonDisplay'
duration:
type: string
layouts:
items:
$ref: '#/components/schemas/DashboardtypesLayout'
nullable: true
type: array
links:
items:
$ref: '#/components/schemas/V1Link'
type: array
panels:
additionalProperties:
$ref: '#/components/schemas/DashboardtypesPanel'
nullable: true
type: object
refreshInterval:
type: string
variables:
items:
$ref: '#/components/schemas/DashboardtypesVariable'
type: array
type: object
DashboardtypesDashboardMetadata:
properties:
image:
type: string
schemaVersion:
type: string
uploadedGrafana:
type: boolean
type: object
DashboardtypesDatasourcePlugin:
oneOf:
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
DashboardtypesDatasourcePluginKind:
enum:
- signoz/Datasource
type: string
DashboardtypesDatasourcePluginVariantStruct:
properties:
kind:
enum:
- signoz/Datasource
type: string
spec:
nullable: true
type: object
required:
- kind
- spec
type: object
DashboardtypesDatasourceSpec:
properties:
default:
type: boolean
display:
$ref: '#/components/schemas/CommonDisplay'
plugin:
$ref: '#/components/schemas/DashboardtypesDatasourcePlugin'
type: object
DashboardtypesDynamicVariableSpec:
properties:
name:
type: string
signal:
$ref: '#/components/schemas/TelemetrytypesSignal'
required:
- name
type: object
DashboardtypesFillMode:
enum:
- solid
- gradient
- none
type: string
DashboardtypesGettableDashboardInfo:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardData'
metadata:
$ref: '#/components/schemas/DashboardtypesDashboardMetadata'
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
type: array
type: object
DashboardtypesGettableDashboardV2:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
id:
type: string
info:
$ref: '#/components/schemas/DashboardtypesGettableDashboardInfo'
locked:
type: boolean
orgId:
type: string
publicConfig:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
type: object
DashboardtypesGettablePublicDasbhboard:
properties:
defaultTimeRange:
@@ -2242,6 +2506,259 @@ components:
publicDashboard:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
nullable: true
type: number
bucketWidth:
nullable: true
type: number
mergeAllActiveQueries:
type: boolean
type: object
DashboardtypesHistogramPanelSpec:
properties:
histogramBuckets:
$ref: '#/components/schemas/DashboardtypesHistogramBuckets'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
type: object
DashboardtypesLayout:
oneOf:
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec'
DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec:
properties:
kind:
enum:
- Grid
type: string
spec:
$ref: '#/components/schemas/DashboardGridLayoutSpec'
required:
- kind
- spec
type: object
DashboardtypesLegend:
properties:
customColors:
additionalProperties:
type: string
nullable: true
type: object
position:
$ref: '#/components/schemas/DashboardtypesLegendPosition'
type: object
DashboardtypesLegendPosition:
enum:
- bottom
- right
type: string
DashboardtypesLineInterpolation:
enum:
- linear
- spline
- step_after
- step_before
type: string
DashboardtypesLineStyle:
enum:
- solid
- dashed
type: string
DashboardtypesListPanelSpec:
properties:
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
type: object
DashboardtypesListVariableSpec:
properties:
allowAllValue:
type: boolean
allowMultiple:
type: boolean
capturingRegexp:
type: string
customAllValue:
type: string
defaultValue:
$ref: '#/components/schemas/VariableDefaultValue'
display:
$ref: '#/components/schemas/VariableDisplay'
name:
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesVariablePlugin'
sort:
nullable: true
type: string
type: object
DashboardtypesNumberPanelSpec:
properties:
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesComparisonThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPanel:
properties:
kind:
type: string
spec:
$ref: '#/components/schemas/DashboardtypesPanelSpec'
type: object
DashboardtypesPanelFormatting:
properties:
decimalPrecision:
$ref: '#/components/schemas/DashboardtypesPrecisionOption'
unit:
type: string
type: object
DashboardtypesPanelPlugin:
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
DashboardtypesPanelPluginKind:
enum:
- signoz/TimeSeriesPanel
- signoz/BarChartPanel
- signoz/NumberPanel
- signoz/PieChartPanel
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
kind:
enum:
- signoz/BarChartPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesBarChartPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
properties:
kind:
enum:
- signoz/HistogramPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesHistogramPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec:
properties:
kind:
enum:
- signoz/ListPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesListPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec:
properties:
kind:
enum:
- signoz/NumberPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesNumberPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec:
properties:
kind:
enum:
- signoz/PieChartPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesPieChartPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
enum:
- signoz/TablePanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTablePanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec:
properties:
kind:
enum:
- signoz/TimeSeriesPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTimeSeriesPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelSpec:
properties:
display:
$ref: '#/components/schemas/V1PanelDisplay'
links:
items:
$ref: '#/components/schemas/V1Link'
type: array
plugin:
$ref: '#/components/schemas/DashboardtypesPanelPlugin'
queries:
items:
$ref: '#/components/schemas/DashboardtypesQuery'
type: array
type: object
DashboardtypesPieChartPanelSpec:
properties:
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPostableDashboardV2:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardData'
metadata:
$ref: '#/components/schemas/DashboardtypesDashboardMetadata'
tags:
items:
$ref: '#/components/schemas/TagtypesPostableTag'
type: array
type: object
DashboardtypesPostablePublicDashboard:
properties:
defaultTimeRange:
@@ -2249,9 +2766,249 @@ components:
timeRangeEnabled:
type: boolean
type: object
DashboardtypesPrecisionOption:
enum:
- "0"
- "1"
- "2"
- "3"
- "4"
- full
type: string
DashboardtypesQuery:
properties:
kind:
type: string
spec:
$ref: '#/components/schemas/DashboardtypesQuerySpec'
type: object
DashboardtypesQueryPlugin:
oneOf:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderTraceOperator'
DashboardtypesQueryPluginKind:
enum:
- signoz/BuilderQuery
- signoz/CompositeQuery
- signoz/Formula
- signoz/PromQLQuery
- signoz/ClickHouseSQL
- signoz/TraceOperator
type: string
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec:
properties:
kind:
enum:
- signoz/BuilderQuery
type: string
spec:
$ref: '#/components/schemas/DashboardtypesBuilderQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery:
properties:
kind:
enum:
- signoz/ClickHouseSQL
type: string
spec:
$ref: '#/components/schemas/Querybuildertypesv5ClickHouseQuery'
required:
- kind
- spec
type: object
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery:
properties:
kind:
enum:
- signoz/CompositeQuery
type: string
spec:
$ref: '#/components/schemas/Querybuildertypesv5CompositeQuery'
required:
- kind
- spec
type: object
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery:
properties:
kind:
enum:
- signoz/PromQLQuery
type: string
spec:
$ref: '#/components/schemas/Querybuildertypesv5PromQuery'
required:
- kind
- spec
type: object
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula:
properties:
kind:
enum:
- signoz/Formula
type: string
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderFormula'
required:
- kind
- spec
type: object
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderTraceOperator:
properties:
kind:
enum:
- signoz/TraceOperator
type: string
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderTraceOperator'
required:
- kind
- spec
type: object
DashboardtypesQuerySpec:
properties:
name:
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesQueryPlugin'
type: object
DashboardtypesQueryVariableSpec:
properties:
queryValue:
type: string
required:
- queryValue
type: object
DashboardtypesSpanGaps:
properties:
fillLessThan:
type: string
fillOnlyBelow:
type: boolean
type: object
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object
DashboardtypesTableFormatting:
properties:
columnUnits:
additionalProperties:
type: string
nullable: true
type: object
decimalPrecision:
$ref: '#/components/schemas/DashboardtypesPrecisionOption'
type: object
DashboardtypesTablePanelSpec:
properties:
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesTableThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesTableThreshold:
properties:
color:
type: string
columnName:
type: string
format:
$ref: '#/components/schemas/DashboardtypesThresholdFormat'
operator:
$ref: '#/components/schemas/DashboardtypesComparisonOperator'
unit:
type: string
value:
format: double
type: number
required:
- value
- color
- columnName
type: object
DashboardtypesThresholdFormat:
enum:
- text
- background
type: string
DashboardtypesThresholdWithLabel:
properties:
color:
type: string
label:
type: string
unit:
type: string
value:
format: double
type: number
required:
- value
- color
- label
type: object
DashboardtypesTimePreference:
enum:
- global_time
- last_5_min
- last_15_min
- last_30_min
- last_1_hr
- last_6_hr
- last_1_day
- last_3_days
- last_1_week
- last_1_month
type: string
DashboardtypesTimeSeriesChartAppearance:
properties:
fillMode:
$ref: '#/components/schemas/DashboardtypesFillMode'
lineInterpolation:
$ref: '#/components/schemas/DashboardtypesLineInterpolation'
lineStyle:
$ref: '#/components/schemas/DashboardtypesLineStyle'
showPoints:
type: boolean
spanGaps:
$ref: '#/components/schemas/DashboardtypesSpanGaps'
type: object
DashboardtypesTimeSeriesPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesTimeSeriesChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesTimeSeriesVisualization'
type: object
DashboardtypesTimeSeriesVisualization:
properties:
fillSpans:
type: boolean
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesUpdatablePublicDashboard:
properties:
defaultTimeRange:
@@ -2259,6 +3016,81 @@ components:
timeRangeEnabled:
type: boolean
type: object
DashboardtypesVariable:
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpec'
DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/DashboardTextVariableSpec'
required:
- kind
- spec
type: object
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec:
properties:
kind:
enum:
- ListVariable
type: string
spec:
$ref: '#/components/schemas/DashboardtypesListVariableSpec'
required:
- kind
- spec
type: object
DashboardtypesVariablePlugin:
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpec'
DashboardtypesVariablePluginKind:
enum:
- signoz/DynamicVariable
- signoz/QueryVariable
- signoz/CustomVariable
type: string
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpec:
properties:
kind:
enum:
- signoz/CustomVariable
type: string
spec:
$ref: '#/components/schemas/DashboardtypesCustomVariableSpec'
required:
- kind
- spec
type: object
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpec:
properties:
kind:
enum:
- signoz/DynamicVariable
type: string
spec:
$ref: '#/components/schemas/DashboardtypesDynamicVariableSpec'
required:
- kind
- spec
type: object
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpec:
properties:
kind:
enum:
- signoz/QueryVariable
type: string
spec:
$ref: '#/components/schemas/DashboardtypesQueryVariableSpec'
required:
- kind
- spec
type: object
ErrorsJSON:
properties:
code:
@@ -5064,6 +5896,20 @@ components:
nullable: true
type: string
type: object
TagtypesGettableTag:
properties:
name:
type: string
required:
- name
type: object
TagtypesPostableTag:
properties:
name:
type: string
required:
- name
type: object
TelemetrytypesFieldContext:
enum:
- metric
@@ -5517,6 +6363,37 @@ components:
required:
- id
type: object
V1Link:
properties:
name:
type: string
renderVariables:
type: boolean
targetBlank:
type: boolean
tooltip:
type: string
url:
type: string
type: object
V1PanelDisplay:
properties:
description:
type: string
name:
type: string
type: object
VariableDefaultValue:
type: object
VariableDisplay:
properties:
description:
type: string
hidden:
type: boolean
name:
type: string
type: object
ZeustypesGettableHost:
properties:
hosts:
@@ -11066,6 +11943,58 @@ paths:
summary: Update user preference
tags:
- preferences
/api/v2/dashboards:
post:
deprecated: false
description: This endpoint creates a v2-shape dashboard with structured metadata,
a typed data tree, and resolved tags.
operationId: CreateDashboardV2
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPostableDashboardV2'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: Created
"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:
- EDITOR
- tokenizer:
- EDITOR
summary: Create dashboard (v2)
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false

View File

@@ -11,8 +11,10 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -30,9 +32,9 @@ type module struct {
licensing licensing.Licensing
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
func NewModule(store dashboardtypes.Store, sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
pkgDashboardModule := pkgimpldashboard.NewModule(store, sqlstore, settings, analytics, orgGetter, queryParser, tagModule)
return &module{
pkgDashboardModule: pkgDashboardModule,
@@ -197,6 +199,72 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy s
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, data)
}
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, postable)
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updateable)
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) CreatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, postable dashboardtypes.PostablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
if _, err := module.licensing.GetActive(ctx, orgID); err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existing, err := module.pkgDashboardModule.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
if existing.PublicConfig != nil {
return nil, errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePublicDashboardAlreadyExists, "dashboard with id %s is already public", id)
}
publicDashboard := dashboardtypes.NewPublicDashboard(postable.TimeRangeEnabled, postable.DefaultTimeRange, id)
if err := module.store.CreatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard)); err != nil {
return nil, err
}
existing.PublicConfig = publicDashboard
return existing, nil
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, deletedBy string) error {
return module.pkgDashboardModule.DeleteV2(ctx, orgID, id, deletedBy)
}
func (module *module) UpdatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable dashboardtypes.UpdatablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
if _, err := module.licensing.GetActive(ctx, orgID); err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existing, err := module.pkgDashboardModule.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
if existing.PublicConfig == nil {
return nil, errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodePublicDashboardNotFound, "dashboard with id %s isn't public", id)
}
existing.PublicConfig.Update(updatable.TimeRangeEnabled, updatable.DefaultTimeRange)
if err := module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(existing.PublicConfig)); err != nil {
return nil, err
}
return existing, nil
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -36,7 +36,6 @@
"@ant-design/icons": "4.8.0",
"@codemirror/autocomplete": "6.18.6",
"@codemirror/lang-javascript": "6.2.3",
"@dagrejs/dagre": "3.0.0",
"@dnd-kit/core": "6.1.0",
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
@@ -64,7 +63,6 @@
"@visx/shape": "3.5.0",
"@visx/tooltip": "3.3.0",
"@vitejs/plugin-react": "5.1.4",
"@xyflow/react": "12.10.2",
"ansi-to-html": "0.7.2",
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
@@ -117,6 +115,7 @@
"react-dom": "18.2.0",
"react-drag-listview": "2.0.0",
"react-error-boundary": "4.0.11",
"react-force-graph-2d": "^1.29.1",
"react-full-screen": "1.1.1",
"react-grid-layout": "^1.3.4",
"react-helmet-async": "1.3.0",

View File

@@ -18,8 +18,10 @@ import type {
} from 'react-query';
import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeletePublicDashboardPathParameters,
@@ -634,3 +636,88 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.
* @summary Create dashboard (v2)
*/
export const createDashboardV2 = (
dashboardtypesPostableDashboardV2DTO: BodyType<DashboardtypesPostableDashboardV2DTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDashboardV2201>({
url: `/api/v2/dashboards`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardV2DTO,
signal,
});
};
export const getCreateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
> => {
const mutationKey = ['createDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createDashboardV2>>,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> }
> = (props) => {
const { data } = props ?? {};
return createDashboardV2(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof createDashboardV2>>
>;
export type CreateDashboardV2MutationBody =
BodyType<DashboardtypesPostableDashboardV2DTO>;
export type CreateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create dashboard (v2)
*/
export const useCreateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
> => {
const mutationOptions = getCreateDashboardV2MutationOptions(options);
return useMutation(mutationOptions);
};

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,6 @@ export enum Events {
TOOLTIP_PINNED = 'TOOLTIP_PINNED',
TOOLTIP_UNPINNED = 'TOOLTIP_UNPINNED',
TOOLTIP_CONTENT_SCROLLED = 'TOOLTIP_CONTENT_SCROLLED',
TOOLTIP_SYNC_MODE_CHANGED = 'TOOLTIP_SYNC_MODE_CHANGED',
}
export enum InfraMonitoringEvents {

View File

@@ -38,5 +38,4 @@ export enum LOCALSTORAGE {
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
}

View File

@@ -107,7 +107,7 @@ describe('BillingContainer', () => {
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Cancel your subscription', { selector: 'span' }),
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
@@ -150,7 +150,7 @@ describe('BillingContainer', () => {
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
await expect(
screen.findByText('Cancel your subscription', { selector: 'span' }),
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
});
@@ -162,7 +162,7 @@ describe('BillingContainer', () => {
it('should render when license is ACTIVATED and platform is CLOUD', async () => {
render(<BillingContainer />);
await expect(
screen.findByText('Cancel your subscription', { selector: 'span' }),
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
@@ -186,7 +186,7 @@ describe('BillingContainer', () => {
);
await screen.findByText('billing');
expect(
screen.queryByText('Cancel your subscription', { selector: 'span' }),
screen.queryByText('Cancel Subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
@@ -225,7 +225,7 @@ describe('BillingContainer', () => {
render(<BillingContainer />, {}, { appContextOverrides: overrides });
await screen.findByText('billing');
expect(
screen.queryByText('Cancel your subscription', { selector: 'span' }),
screen.queryByText('Cancel Subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,11 +1,11 @@
.banner {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-items: center;
padding: var(--padding-4);
border-radius: 4px;
border: 1px solid var(--l1-border);
background-color: var(--l2-background);
border: 1px solid var(--callout-error-border);
background-color: var(--callout-error-background);
margin: var(--spacing-4) 0 var(--spacing-12);
}
@@ -15,55 +15,21 @@
gap: var(--spacing-2);
}
.titleRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.title {
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
line-height: var(--paragraph-base-500-line-height);
color: var(--l1-foreground);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--callout-error-title);
}
.subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
padding-left: 20px;
color: var(--callout-error-icon);
}
.dialogBody {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.dialogDescription {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
color: var(--l2-foreground);
margin: 0;
}
.dialogConfirmLabel {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
margin: 0;
code {
font-family: var(--font-mono);
color: var(--l1-foreground);
}
}
.cancelButton {
background: var(--secondary-background);
border: 1px solid var(--l1-border);
}

View File

@@ -1,4 +1,4 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, userEvent } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
@@ -13,16 +13,14 @@ describe('CancelSubscriptionBanner', () => {
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
screen.getByText('Cancel your subscription', { selector: 'span' }),
screen.getByText('Cancel Subscription', { selector: 'span' }),
).toBeInTheDocument();
expect(
screen.getByText(
/When you cancel your SigNoz subscription, all your data will be deleted/i,
),
screen.getByText('Cancel your SigNoz subscription.'),
).toBeInTheDocument();
});
it('opens dialog with content when Cancel Subscription is clicked', async () => {
it('opens dialog with correct content when Cancel Subscription is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
@@ -32,62 +30,17 @@ describe('CancelSubscriptionBanner', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
screen.getByText(/Cancelling your subscription would stop your data/i),
screen.getByText(/reach out to our support team/i),
).toBeInTheDocument();
expect(screen.getByText(/Type/i)).toBeInTheDocument();
expect(
screen.getByPlaceholderText(/Enter the word cancel/i),
screen.getByRole('button', { name: /keep subscription/i }),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /cancel subscription/i }),
screen.getByRole('button', { name: /contact support/i }),
).toBeInTheDocument();
});
it('keeps Cancel subscription button disabled until "cancel" is typed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const confirmButton = screen.getByRole('button', {
name: /cancel subscription/i,
});
expect(confirmButton).toBeDisabled();
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'canc');
expect(confirmButton).toBeDisabled();
await user.type(input, 'el');
expect(confirmButton).toBeEnabled();
});
it('closes dialog and resets input when Go back is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
await user.click(screen.getByRole('button', { name: /go back/i }));
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(screen.getByPlaceholderText(/Enter the word cancel/i)).toHaveValue('');
});
it('sends mailto to cloud-support with correct subject after typing "cancel"', async () => {
it('sends mailto to cloud-support with correct subject on Contact Support', async () => {
const realCreateElement = document.createElement.bind(document);
const mockClick = jest.fn();
const mockAnchor = { href: '', click: mockClick };
@@ -104,13 +57,7 @@ describe('CancelSubscriptionBanner', () => {
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.click(screen.getByRole('button', { name: /contact support/i }));
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');

View File

@@ -1,17 +1,15 @@
import { useState } from 'react';
import { SolidInfoCircle, Undo2, X } from '@signozhq/icons';
import { Button, DialogWrapper, Input } from '@signozhq/ui';
import { X } from '@signozhq/icons';
import { Button, DialogWrapper } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { getBaseUrl } from 'utils/basePath';
import styles from './CancelSubscriptionBanner.module.scss';
import { Color } from '@signozhq/design-tokens';
function CancelSubscriptionBanner(): JSX.Element {
const [open, setOpen] = useState(false);
const [confirmText, setConfirmText] = useState('');
const { user, org } = useAppContext();
const handleOpenCancelDialog = (): void => {
@@ -55,12 +53,6 @@ function CancelSubscriptionBanner(): JSX.Element {
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
link.click();
setOpen(false);
setConfirmText('');
};
const handleClose = (): void => {
setOpen(false);
setConfirmText('');
};
const footer = (
@@ -68,19 +60,12 @@ function CancelSubscriptionBanner(): JSX.Element {
<Button
variant="solid"
color="secondary"
prefix={<Undo2 size={14} />}
onClick={handleClose}
onClick={(): void => setOpen(false)}
>
Go back
Keep Subscription
</Button>
<Button
variant="solid"
color="destructive"
prefix={<X size={14} />}
disabled={confirmText !== 'cancel'}
onClick={handleContactSupport}
>
Cancel subscription
<Button variant="solid" color="destructive" onClick={handleContactSupport}>
Contact Support
</Button>
</>
);
@@ -89,47 +74,30 @@ function CancelSubscriptionBanner(): JSX.Element {
<>
<div className={styles.banner}>
<div className={styles.info}>
<div className={styles.titleRow}>
<SolidInfoCircle color={Color.BG_SAKURA_500} size={12} />
<span className={styles.title}>Cancel your subscription</span>
</div>
<span className={styles.subtitle}>
When you cancel your SigNoz subscription, all your data will be deleted
immediately and removed from our servers.
</span>
<span className={styles.title}>Cancel Subscription</span>
<span className={styles.subtitle}>Cancel your SigNoz subscription.</span>
</div>
<Button
variant="solid"
color="secondary"
color="destructive"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
className={styles.cancelButton}
>
Cancel Subscription
</Button>
</div>
<DialogWrapper
open={open}
onOpenChange={handleClose}
title="Cancel your subscription?"
onOpenChange={setOpen}
title="Cancel your subscription"
width="narrow"
showCloseButton={false}
footer={footer}
>
<div className={styles.dialogBody}>
<p className={styles.dialogDescription}>
Cancelling your subscription would stop your data from being ingested to
SigNoz. All the data that has been already sent will also be deleted.
</p>
<p className={styles.dialogConfirmLabel}>
Type <code>cancel</code> to confirm the cancellation.
</p>
<Input
placeholder="Enter the word cancel..."
value={confirmText}
onChange={(e): void => setConfirmText(e.target.value)}
/>
</div>
<p className={styles.dialogBody}>
To cancel your SigNoz subscription, please reach out to our support team.
We&apos;ll be happy to assist you.
</p>
</DialogWrapper>
</>
);

View File

@@ -1,190 +0,0 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.overviewSettings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.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 {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -0,0 +1,143 @@
.overview-content {
display: flex;
flex-direction: column;
.overview-settings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
.name-icon-input {
display: flex;
.dashboard-image-input {
.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);
background: var(--l3-background);
.ant-select-selection-item {
display: flex;
align-items: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}
.dashboard-name-input {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.dashboard-name {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.description-text-area {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.overview-settings-footer {
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;
.unsaved-dot {
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);
}
.unsaved-changes {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 171.429% */
letter-spacing: -0.07px;
}
}
.footer-action-btns {
display: flex;
gap: 8px;
.discard-btn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.save-btn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
}
}
}
.dashboard-image-input {
&.ant-select-dropdown {
padding: 0px !important;
}
.ant-select-item {
padding: 0px;
align-items: center;
justify-content: center;
.ant-select-item-option-content {
display: flex;
align-items: center;
justify-content: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}

View File

@@ -1,24 +1,16 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
import { Col, Input, Select, Space, Typography } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
import { Button } from './styles';
import { Base64Icons } from './utils';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
import './GeneralSettings.styles.scss';
const { Option } = Select;
@@ -27,13 +19,6 @@ function GeneralDashboardSettings(): JSX.Element {
const updateDashboardMutation = useUpdateDashboard();
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
dashboardData?.id,
);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(dashboardData?.id);
const selectedData = dashboardData?.data;
const {
@@ -115,8 +100,8 @@ function GeneralDashboardSettings(): JSX.Element {
};
return (
<div className={styles.overviewContent}>
<Col className={styles.overviewSettings}>
<div className="overview-content">
<Col className="overview-settings">
<Space
direction="vertical"
style={{
@@ -127,29 +112,27 @@ function GeneralDashboardSettings(): JSX.Element {
}}
>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Dashboard Name
</Typography>
<section className="name-icon-input">
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
rootClassName="dashboard-image-input"
value={updatedImage}
onChange={(value: string): void => setUpdatedImage(value)}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
<img src={icon} alt="dashboard-icon" className="list-item-image" />
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
className="dashboard-name-input"
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
@@ -157,92 +140,41 @@ function GeneralDashboardSettings(): JSX.Element {
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Description
</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={updatedDescription}
className={styles.descriptionTextArea}
className="description-text-area"
onChange={(e): void => setUpdatedDescription(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Tags
</Typography>
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
</div>
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</Col>
{numberOfUnsavedChanges > 0 && (
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
<div className="overview-settings-footer">
<div className="unsaved">
<div className="unsaved-dot" />
<Typography.Text className="unsaved-changes">
{numberOfUnsavedChanges} unsaved change
{numberOfUnsavedChanges > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionBtns}>
<div className="footer-action-btns">
<Button
disabled={updateDashboardMutation.isLoading}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className={styles.discardBtn}
className="discard-btn"
>
Discard
</Button>
@@ -256,7 +188,7 @@ function GeneralDashboardSettings(): JSX.Element {
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className={styles.saveBtn}
className="save-btn"
>
{t('save')}
</Button>

View File

@@ -33,13 +33,11 @@ export default function BarChart(props: BarChartProps): JSX.Element {
}
const tooltipProps: BarTooltipProps = {
...props,
id: config.getId(),
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <BarChartTooltip {...tooltipProps} />;
},
@@ -50,7 +48,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);

View File

@@ -29,12 +29,11 @@ export default function ChartWrapper({
onClick,
syncMode,
syncKey,
syncFilterMode,
onDestroy = noop,
children,
layoutChildren,
yAxisUnit,
groupByPerQuery,
groupBy,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
@@ -70,10 +69,9 @@ export default function ChartWrapper({
const syncMetadata = useMemo(
() => ({
yAxisUnit,
groupByPerQuery,
filterMode: syncFilterMode,
groupBy,
}),
[yAxisUnit, groupByPerQuery, syncFilterMode],
[yAxisUnit, groupBy],
);
return (

View File

@@ -24,21 +24,13 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
}
const tooltipProps: HistogramTooltipProps = {
...props,
id: rest.config.getId(),
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <HistogramTooltip {...tooltipProps} />;
},
[
customTooltip,
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
);
return (

View File

@@ -9,7 +9,7 @@ import {
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, customTooltip, ...rest } = props;
const { children, customTooltip, pinnedTooltipElement, ...rest } = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
@@ -18,12 +18,10 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
}
const tooltipProps: TimeSeriesTooltipProps = {
...props,
id: rest.config.getId(),
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <TimeSeriesTooltip {...tooltipProps} />;
},
@@ -33,12 +31,15 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);
return (
<ChartWrapper {...rest} customTooltip={renderTooltip}>
<ChartWrapper
{...rest}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>
);

View File

@@ -1,14 +1,9 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import {
IRenderTooltipFooterArgs,
LegendConfig,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
TooltipClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -26,7 +21,6 @@ interface BaseChartProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
}
@@ -36,7 +30,6 @@ interface UPlotBasedChartProps {
legendConfig: LegendConfig;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncFilterMode?: SyncTooltipFilterMode;
plotRef?: (plot: uPlot | null) => void;
onDestroy?: (plot: uPlot) => void;
children?: React.ReactNode;
@@ -46,7 +39,7 @@ interface UPlotBasedChartProps {
interface UPlotChartDataProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
groupBy?: BaseAutocompleteData[];
}
export interface TimeSeriesChartProps

View File

@@ -1,15 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
@@ -20,7 +14,7 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import get from 'lodash/get';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -30,7 +24,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
onDragSelect,
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
} = props;
const uPlotRef = useRef<uPlot | null>(null);
const graphRef = useRef<HTMLDivElement>(null);
@@ -41,10 +34,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -86,11 +75,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
maxTimeScale,
timezone,
panelMode,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const chartData = useMemo(() => {
@@ -130,20 +114,14 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
uPlotRef.current = plot;
}, []);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
);
},
[],
);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -155,14 +133,11 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
groupBy={groupBy}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -1,11 +1,8 @@
import { useCallback, useMemo, useRef } from 'react';
import { useMemo, useRef } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram';
@@ -16,7 +13,6 @@ import {
} from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -79,20 +75,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
widget.mergeAllActiveQueries,
]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter
id={widget.id}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
);
},
[],
);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
@@ -115,7 +97,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
renderTooltipFooter={renderTooltipFooter}
/>
)}
</div>

View File

@@ -1,26 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import get from 'lodash/get';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -30,7 +24,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
onDragSelect,
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
} = props;
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
@@ -40,10 +33,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -92,11 +81,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
minTimeScale,
maxTimeScale,
timezone,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const layoutChildren = useMemo(() => {
@@ -121,20 +105,14 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
widget.decimalPrecision,
]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
);
},
[],
);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -144,13 +122,10 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
data={chartData as uPlot.AlignedData}
groupByPerQuery={groupByPerQuery}
groupBy={groupBy}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
layoutChildren={layoutChildren}
renderTooltipFooter={renderTooltipFooter}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -1,93 +0,0 @@
import { Events } from 'constants/events';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { render, screen, userEvent } from 'tests/test-utils';
import TooltipFooter from '../TooltipFooter';
const mockLogEvent = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): unknown => mockLogEvent(...args),
}));
describe('TooltipFooter', () => {
const defaultProps = {
id: 'panel-123',
isPinned: false,
dismiss: jest.fn(),
};
describe('when not pinned', () => {
it('renders the drilldown and pin hints by default', () => {
render(<TooltipFooter {...defaultProps} />);
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
expect(
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
).toBeInTheDocument();
});
it('hides the drilldown hint when canDrilldown is false', () => {
render(<TooltipFooter {...defaultProps} canDrilldown={false} />);
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
});
it('renders a custom pin key in uppercase', () => {
render(<TooltipFooter {...defaultProps} pinKey="x" />);
expect(screen.getByText('X')).toBeInTheDocument();
});
it('does not render the unpin button', () => {
render(<TooltipFooter {...defaultProps} />);
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
});
});
describe('when pinned', () => {
it('renders the unpin hint with pin key and Esc', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.getByText('to unpin')).toBeInTheDocument();
expect(
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
).toBeInTheDocument();
expect(screen.getByText('Esc')).toBeInTheDocument();
});
it('renders the unpin button', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.getByTestId('uplot-tooltip-unpin')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /unpin tooltip/i }),
).toBeInTheDocument();
});
it('hides the drilldown and pin-instruction hints', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
expect(screen.queryByText('to pin the tooltip')).not.toBeInTheDocument();
});
it('calls dismiss and logs the unpin event when the unpin button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const dismiss = jest.fn();
render(<TooltipFooter {...defaultProps} dismiss={dismiss} isPinned />);
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
id: 'panel-123',
});
expect(dismiss).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,6 +1,5 @@
import { FC, useMemo } from 'react';
import { FC } from 'react';
import Spinner from 'components/Spinner';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { PanelTypeVsPanelWrapper } from './constants';
import { PanelWrapperProps } from './panelWrapper.types';
@@ -31,20 +30,6 @@ function PanelWrapper({
selectedGraph || widget.panelTypes
] as FC<PanelWrapperProps>;
const groupByPerQuery = useMemo<Record<string, BaseAutocompleteData[]>>(() => {
if (!widget.query.builder) {
return {};
}
const { queryData } = widget.query.builder;
return queryData.reduce<Record<string, BaseAutocompleteData[]>>(
(acc, query) => {
acc[query.queryName] = query.groupBy ?? [];
return acc;
},
{},
);
}, [widget]);
if (!Component) {
return <></>;
}
@@ -75,7 +60,6 @@ function PanelWrapper({
customSeries={customSeries}
enableDrillDown={enableDrillDown}
onColumnWidthsChange={onColumnWidthsChange}
groupByPerQuery={groupByPerQuery}
/>
);
}

View File

@@ -5,7 +5,6 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
import { WidgetGraphComponentProps } from 'container/GridCardLayout/GridCard/types';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
@@ -31,7 +30,6 @@ export type PanelWrapperProps = {
enableDrillDown?: boolean;
panelMode: PanelMode;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
};
export type TooltipData = {

View File

@@ -1,150 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardCursorSyncMode } from '../useDashboardCursorSyncMode';
import { useDashboardPreferencesStore } from '../useDashboardPreference';
const STORAGE_KEY = LOCALSTORAGE.DASHBOARD_PREFERENCES;
describe('useDashboardCursorSyncMode', () => {
beforeEach(() => {
useDashboardPreferencesStore.setState({ preferences: {} });
localStorage.removeItem(STORAGE_KEY);
});
describe('in DASHBOARD_VIEW mode', () => {
it('uses Crosshair as the default cursor sync mode', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
it('reads the stored cursor sync mode for the dashboard', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('writes the value under the cursorSyncMode key in the store', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(result.current[0]).toBe(DashboardCursorSync.None);
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('persists the value to localStorage', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}');
expect(persisted.state.preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('returns the default when dashboardId is undefined', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
);
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
it('treats the setter as a no-op when dashboardId is undefined', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
);
act(() => {
result.current[1](DashboardCursorSync.Tooltip);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
{},
);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
});
describe('without a panelMode (e.g. dashboard settings call site)', () => {
it('reads the stored value just like DASHBOARD_VIEW does', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('writes through the setter to the store', () => {
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(result.current[0]).toBe(DashboardCursorSync.None);
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
});
describe.each([[PanelMode.DASHBOARD_EDIT], [PanelMode.STANDALONE_VIEW]])(
'in %s mode (cursor sync disabled)',
(panelMode) => {
it('returns the Crosshair default and ignores any stored value', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', panelMode),
);
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
it('treats the setter as a no-op and does not write to the store', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', panelMode),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
{},
);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
},
);
});

View File

@@ -1,201 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import {
useDashboardPreference,
useDashboardPreferencesStore,
} from '../useDashboardPreference';
const DEFAULT_MODE = DashboardCursorSync.Crosshair;
describe('useDashboardPreference', () => {
beforeEach(() => {
useDashboardPreferencesStore.setState({ preferences: {} });
});
it('returns the default value when no preference is stored', () => {
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('returns the default value when dashboardId is undefined', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('returns the stored value for the given dashboardId', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
},
});
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('persists the new value via the setter', () => {
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(result.current[0]).toBe(DashboardCursorSync.None);
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('does not write when dashboardId is undefined', () => {
const { result } = renderHook(() =>
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.Tooltip);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('keeps multiple hook instances in sync after a write', () => {
const { result: writer } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
const { result: reader } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
writer.current[1](DashboardCursorSync.Tooltip);
});
expect(writer.current[0]).toBe(DashboardCursorSync.Tooltip);
expect(reader.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('isolates preferences across different dashboardIds', () => {
const { result: dashOne } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
const { result: dashTwo } = renderHook(() =>
useDashboardPreference('dash-2', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
dashOne.current[1](DashboardCursorSync.None);
});
expect(dashOne.current[0]).toBe(DashboardCursorSync.None);
expect(dashTwo.current[0]).toBe(DEFAULT_MODE);
});
it('does not overwrite preferences for other dashboards when writing', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
});
});
});
describe('useDashboardPreferencesStore.removePreferences', () => {
beforeEach(() => {
useDashboardPreferencesStore.setState({ preferences: {} });
});
it('removes the preferences for the given dashboardId', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
},
});
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
});
it('leaves other dashboards untouched', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
},
});
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('is a no-op when the dashboardId is not present', () => {
const initial = {
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
};
useDashboardPreferencesStore.setState({ preferences: initial });
const before = useDashboardPreferencesStore.getState().preferences;
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
// Identity-preserving so subscribers reading `preferences` don't re-render.
expect(useDashboardPreferencesStore.getState().preferences).toBe(before);
});
it('causes subsequent reads via useDashboardPreference to fall back to the default', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
},
});
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
expect(result.current[0]).toBe(DEFAULT_MODE);
});
});

View File

@@ -1,28 +0,0 @@
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardPreference } from './useDashboardPreference';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
const DEFAULT_CURSOR_SYNC_MODE = DashboardCursorSync.Crosshair;
const NOOP = (): void => {};
export function useDashboardCursorSyncMode(
dashboardId: string | undefined,
panelMode?: PanelMode,
): [DashboardCursorSync, (value: DashboardCursorSync) => void] {
const [value, setValue] = useDashboardPreference(
dashboardId,
'cursorSyncMode',
DEFAULT_CURSOR_SYNC_MODE,
);
// Chart panels in edit / standalone modes don't participate in cross-panel
// sync, so surface the default with a no-op setter for them. Callers without
// a panelMode (e.g. dashboard settings) read/write the preference normally.
if (panelMode && panelMode !== PanelMode.DASHBOARD_VIEW) {
return [DEFAULT_CURSOR_SYNC_MODE, NOOP];
}
return [value, setValue];
}

View File

@@ -1,88 +0,0 @@
import { useCallback } from 'react';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
// Per-dashboard preferences persisted in localStorage. Add new preference
// fields here as they are introduced.
export type DashboardPreferences = {
cursorSyncMode?: DashboardCursorSync;
syncTooltipFilterMode?: SyncTooltipFilterMode;
};
interface DashboardPreferencesState {
preferences: Record<string, DashboardPreferences>;
setPreference: <K extends keyof DashboardPreferences>(
dashboardId: string,
key: K,
value: NonNullable<DashboardPreferences[K]>,
) => void;
removePreferences: (dashboardId: string) => void;
}
export const useDashboardPreferencesStore = create<DashboardPreferencesState>()(
persist(
(set) => ({
preferences: {},
setPreference: (dashboardId, key, value): void => {
set((state) => ({
preferences: {
...state.preferences,
[dashboardId]: {
...state.preferences[dashboardId],
[key]: value,
},
},
}));
},
removePreferences: (dashboardId): void => {
set((state) => {
if (!(dashboardId in state.preferences)) {
return state;
}
const { [dashboardId]: _, ...rest } = state.preferences;
return { preferences: rest };
});
},
}),
{ name: LOCALSTORAGE.DASHBOARD_PREFERENCES },
),
);
export function useDashboardPreference<K extends keyof DashboardPreferences>(
dashboardId: string | undefined,
key: K,
defaultValue: NonNullable<DashboardPreferences[K]>,
): [
NonNullable<DashboardPreferences[K]>,
(value: NonNullable<DashboardPreferences[K]>) => void,
] {
type Value = NonNullable<DashboardPreferences[K]>;
const value = useDashboardPreferencesStore((state): Value => {
if (!dashboardId) {
return defaultValue;
}
return (
(state.preferences[dashboardId]?.[key] as Value | undefined) ?? defaultValue
);
});
const setPreference = useDashboardPreferencesStore((s) => s.setPreference);
const updateValue = useCallback(
(next: Value): void => {
if (!dashboardId) {
return;
}
setPreference(dashboardId, key, next);
},
[dashboardId, key, setPreference],
);
return [value, updateValue];
}

View File

@@ -5,15 +5,10 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { useDashboardPreferencesStore } from './useDashboardPreference';
export const useDeleteDashboard = (
id: string,
): UseMutationResult<SuccessResponseV2<null>, APIError, void, unknown> => {
const { showErrorModal } = useErrorModal();
const removePreferences = useDashboardPreferencesStore(
(state) => state.removePreferences,
);
return useMutation<SuccessResponseV2<null>, APIError>({
mutationKey: REACT_QUERY_KEY.DELETE_DASHBOARD,
@@ -21,9 +16,6 @@ export const useDeleteDashboard = (
deleteDashboard({
id,
}),
onSuccess: () => {
removePreferences(id);
},
onError: (error: APIError) => {
showErrorModal(error);
},

View File

@@ -1,15 +0,0 @@
import { SyncTooltipFilterMode } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardPreference } from './useDashboardPreference';
const DEFAULT_SYNC_TOOLTIP_FILTER_MODE = SyncTooltipFilterMode.Filtered;
export function useSyncTooltipFilterMode(
dashboardId: string | undefined,
): [SyncTooltipFilterMode, (value: SyncTooltipFilterMode) => void] {
return useDashboardPreference(
dashboardId,
'syncTooltipFilterMode',
DEFAULT_SYNC_TOOLTIP_FILTER_MODE,
);
}

View File

@@ -17,7 +17,6 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
decimalPrecision: props.decimalPrecision,
isStackedBarChart: props.isStackedBarChart,
syncedSeriesIndexes: props.syncedSeriesIndexes,
syncFilterMode: props.syncFilterMode,
}),
[
props.uPlotInstance,
@@ -27,7 +26,6 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
props.decimalPrecision,
props.isStackedBarChart,
props.syncedSeriesIndexes,
props.syncFilterMode,
],
);

View File

@@ -18,7 +18,6 @@ export default function HistogramTooltip(
yAxisUnit: props.yAxisUnit ?? '',
decimalPrecision: props.decimalPrecision,
syncedSeriesIndexes: props.syncedSeriesIndexes,
syncFilterMode: props.syncFilterMode,
}),
[
props.uPlotInstance,
@@ -27,7 +26,6 @@ export default function HistogramTooltip(
props.yAxisUnit,
props.decimalPrecision,
props.syncedSeriesIndexes,
props.syncFilterMode,
],
);

View File

@@ -18,7 +18,6 @@ export default function TimeSeriesTooltip(
yAxisUnit: props.yAxisUnit ?? '',
decimalPrecision: props.decimalPrecision,
syncedSeriesIndexes: props.syncedSeriesIndexes,
syncFilterMode: props.syncFilterMode,
}),
[
props.uPlotInstance,
@@ -27,7 +26,6 @@ export default function TimeSeriesTooltip(
props.yAxisUnit,
props.decimalPrecision,
props.syncedSeriesIndexes,
props.syncFilterMode,
],
);

View File

@@ -8,15 +8,15 @@
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
gap: 8px;
&.pinned {
border-color: var(--ring);
}
}
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
.divider {
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
}

View File

@@ -2,19 +2,19 @@ import { useMemo } from 'react';
import cx from 'classnames';
import { TooltipProps } from '../types';
import TooltipFooter from './components/TooltipFooter/TooltipFooter';
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
import TooltipList from './components/TooltipList/TooltipList';
import Styles from './Tooltip.module.scss';
export default function Tooltip({
id,
uPlotInstance,
timezone,
content,
showTooltipHeader = true,
isPinned,
renderTooltipFooter,
canPinTooltip,
dismiss,
}: TooltipProps): JSX.Element {
const tooltipContent = useMemo(() => content ?? [], [content]);
@@ -31,9 +31,7 @@ export default function Tooltip({
return (
<div
className={cx(Styles.container, {
[Styles.pinned]: isPinned,
})}
className={cx(Styles.container, isPinned && Styles.pinned)}
data-testid="uplot-tooltip-container"
>
{showHeader && (
@@ -48,9 +46,9 @@ export default function Tooltip({
{showDivider && <span className={Styles.divider} />}
{showList && <TooltipList id={id} content={tooltipContent} />}
{showList && <TooltipList content={tooltipContent} />}
{renderTooltipFooter && renderTooltipFooter({ isPinned, dismiss })}
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
</div>
);
}

View File

@@ -7,7 +7,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { render, RenderResult, screen } from 'tests/test-utils';
import uPlot from 'uplot';
import { IRenderTooltipFooterArgs, TooltipContentItem } from '../../types';
import { TooltipContentItem } from '../../types';
import Tooltip from '../Tooltip';
type MockVirtuosoProps = {
@@ -83,7 +83,6 @@ function createUPlotInstance(cursorIdx: number | null): uPlot {
function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
const defaultProps: TooltipTestProps = {
id: 'tooltip-1',
uPlotInstance: createUPlotInstance(null),
timezone: { value: 'UTC', name: 'UTC', offset: '0', searchIndex: '0' },
content: [],
@@ -193,88 +192,63 @@ describe('Tooltip', () => {
});
});
describe('Tooltip renderTooltipFooter', () => {
describe('Tooltip footer hint', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(false);
});
it('does not render footer content when renderTooltipFooter is not provided', () => {
renderTooltip();
it('renders footer with "Press P to pin the tooltip" hint when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
expect(screen.queryByTestId('custom-tooltip-footer')).not.toBeInTheDocument();
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toBeInTheDocument();
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('to pin the tooltip');
});
it('renders content returned by renderTooltipFooter', () => {
const renderTooltipFooter = jest.fn(
(): JSX.Element => <div data-testid="custom-tooltip-footer">Footer</div>,
);
it('renders footer with "Press P or Esc to unpin" hint when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
renderTooltip({ renderTooltipFooter });
expect(screen.getByTestId('custom-tooltip-footer')).toBeInTheDocument();
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('Esc');
expect(footer).toHaveTextContent('to unpin');
});
it('calls renderTooltipFooter with isPinned=false when tooltip is not pinned', () => {
const renderTooltipFooter = jest.fn(() => null);
it('does not render Unpin button when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
renderTooltip({ renderTooltipFooter, isPinned: false });
expect(renderTooltipFooter).toHaveBeenCalledWith(
expect.objectContaining({ isPinned: false }),
);
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
});
it('calls renderTooltipFooter with isPinned=true when tooltip is pinned', () => {
const renderTooltipFooter = jest.fn(() => null);
it('renders Unpin button when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
renderTooltip({ renderTooltipFooter, isPinned: true });
expect(renderTooltipFooter).toHaveBeenCalledWith(
expect.objectContaining({ isPinned: true }),
);
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
expect(unpinBtn).toBeInTheDocument();
expect(unpinBtn).toHaveAttribute('aria-label', 'Unpin tooltip');
});
it('calls renderTooltipFooter with the dismiss callback', () => {
it('calls dismiss when Unpin button is clicked', async () => {
const dismiss = jest.fn();
const renderTooltipFooter = jest.fn(() => null);
renderTooltip({ renderTooltipFooter, dismiss });
expect(renderTooltipFooter).toHaveBeenCalledWith(
expect.objectContaining({ dismiss }),
);
});
it('footer content reflects pinned state via renderTooltipFooter args', () => {
const renderTooltipFooter = jest.fn(
({ isPinned }: IRenderTooltipFooterArgs): JSX.Element => (
<div data-testid="footer-state">{isPinned ? 'Pinned' : 'Not pinned'}</div>
),
);
renderTooltip({ renderTooltipFooter, isPinned: true });
expect(screen.getByTestId('footer-state')).toHaveTextContent('Pinned');
});
it('dismiss is callable when invoked from renderTooltipFooter', async () => {
const dismiss = jest.fn();
const renderTooltipFooter = jest.fn(
({ dismiss: onDismiss }: IRenderTooltipFooterArgs): JSX.Element => (
<button data-testid="dismiss-btn" onClick={onDismiss}>
Dismiss
</button>
),
);
renderTooltip({ renderTooltipFooter, isPinned: true, dismiss });
renderTooltip({ isPinned: true, canPinTooltip: true, dismiss });
const user = userEvent.setup();
await user.click(screen.getByTestId('dismiss-btn'));
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
await user.click(unpinBtn);
expect(dismiss).toHaveBeenCalledTimes(1);
});
it('footer has role="status" for screen reader announcements', () => {
renderTooltip({ canPinTooltip: true });
const footer = screen.getByRole('status');
expect(footer).toBeInTheDocument();
});
});
describe('Tooltip header status pill', () => {

View File

@@ -7,25 +7,22 @@ import Styles from './TooltipFooter.module.scss';
import { MousePointerClick } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
interface TooltipFooterProps {
id: string;
pinKey?: string;
isPinned: boolean;
canDrilldown?: boolean;
dismiss: () => void;
}
export default function TooltipFooter({
id,
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
isPinned,
canDrilldown = true,
dismiss,
}: TooltipFooterProps): JSX.Element {
const handleUnpinClick = (): void => {
logEvent(Events.TOOLTIP_UNPINNED, {
id: id,
path: getAbsoluteUrl(window.location.pathname),
});
dismiss();
};
@@ -46,14 +43,12 @@ export default function TooltipFooter({
</div>
) : (
<div className={Styles.hintList}>
{canDrilldown && (
<div className={Styles.hint} data-active="false">
<Kbd>
<MousePointerClick size={12} />
</Kbd>
<span>Click to drilldown</span>
</div>
)}
<div className={Styles.hint} data-active="false">
<Kbd>
<MousePointerClick size={12} />
</Kbd>
<span>Click to drilldown</span>
</div>
<div className={Styles.hint} data-active="false">
<span>Press</span>
<Kbd>{pinKey.toUpperCase()}</Kbd>

View File

@@ -11,10 +11,11 @@
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
margin-bottom: var(--spacing-2);
}
.pinnedItem {
padding: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-4) 0 var(--spacing-4);
}
.status {

View File

@@ -1,13 +1,9 @@
.container {
padding-bottom: var(--spacing-6);
}
.list {
width: 100%;
:global(div[data-viewport-type='element']) {
left: 0;
box-sizing: border-box;
padding: var(--spacing-4) var(--spacing-2) var(--spacing-4) var(--spacing-4);
padding: 0px var(--spacing-2) 0 var(--spacing-4);
[data-test-id='virtuoso-item-list'] > * + * {
margin-top: var(--spacing-2);

View File

@@ -9,18 +9,17 @@ import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import Styles from './TooltipList.module.scss';
import { getAbsoluteUrl } from 'utils/basePath';
// Fallback per-item height before Virtuoso reports the real total.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
interface TooltipListProps {
id: string;
content: TooltipContentItem[];
}
export default function TooltipList({
id,
content,
}: TooltipListProps): JSX.Element {
const isDarkMode = useIsDarkMode();
@@ -42,25 +41,23 @@ export default function TooltipList({
if (!isScrollEventTriggered.current) {
// TODO: remove event in July 2026
logEvent(Events.TOOLTIP_CONTENT_SCROLLED, {
id,
path: getAbsoluteUrl(window.location.pathname),
});
isScrollEventTriggered.current = true;
}
}, []);
return (
<div className={Styles.container}>
<Virtuoso
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
data-testid="uplot-tooltip-list"
data={content}
onScroll={handleScroll}
style={{ height }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
)}
/>
</div>
<Virtuoso
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
data-testid="uplot-tooltip-list"
data={content}
onScroll={handleScroll}
style={{ height }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={false} />
)}
/>
);
}

View File

@@ -2,7 +2,6 @@ import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import uPlot, { AlignedData, Series } from 'uplot';
import { SyncTooltipFilterMode } from '../../plugins/TooltipPlugin/types';
import { TooltipContentItem } from '../types';
export const FALLBACK_SERIES_COLOR = '#000000';
@@ -64,7 +63,6 @@ export function buildTooltipContent({
decimalPrecision,
isStackedBarChart,
syncedSeriesIndexes,
syncFilterMode,
}: {
data: AlignedData;
series: Series[];
@@ -75,16 +73,10 @@ export function buildTooltipContent({
decimalPrecision?: PrecisionOption;
isStackedBarChart?: boolean;
syncedSeriesIndexes?: number[] | null;
syncFilterMode?: SyncTooltipFilterMode;
}): TooltipContentItem[] {
const items: TooltipContentItem[] = [];
const matchedIndexes =
syncedSeriesIndexes != null ? new Set(syncedSeriesIndexes) : null;
const filterMode = syncFilterMode ?? SyncTooltipFilterMode.Filtered;
// In Filtered mode the matched indexes act as a whitelist; in All mode every
// series renders and matched indexes only drive row highlighting.
const allowedIndexes =
filterMode === SyncTooltipFilterMode.All ? null : matchedIndexes;
syncedSeriesIndexes != null ? new Set(syncedSeriesIndexes) : null;
for (let seriesIndex = 1; seriesIndex < series.length; seriesIndex += 1) {
const seriesItem = series[seriesIndex];
@@ -97,7 +89,6 @@ export function buildTooltipContent({
const dataIndex = dataIndexes[seriesIndex];
const isSync = allowedIndexes != null;
const isHighlighted = matchedIndexes?.has(seriesIndex) ?? false;
if (dataIndex === null) {
if (isSync) {
@@ -107,7 +98,6 @@ export function buildTooltipContent({
tooltipValue: 'No Data',
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
isActive: false,
isHighlighted,
});
}
continue;
@@ -128,7 +118,6 @@ export function buildTooltipContent({
tooltipValue: getToolTipValue(baseValue, yAxisUnit, decimalPrecision),
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
isActive: seriesIndex === activeSeriesIndex,
isHighlighted,
});
} else if (isSync) {
items.push({
@@ -137,7 +126,6 @@ export function buildTooltipContent({
tooltipValue: 'No Data',
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
isActive: false,
isHighlighted,
});
}
}

View File

@@ -4,7 +4,6 @@ import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
* Props for the Plot component
@@ -59,31 +58,17 @@ export interface TooltipRenderArgs {
isPinned: boolean;
dismiss: () => void;
viaSync: boolean;
/** In Tooltip sync mode, identifies receiver series that match the source's
* focused series on the shared groupBy keys.
* Filtered mode: limits which series are rendered (null = no filter,
* [] = no matches/tooltip hidden upstream, [...] = allowed indexes).
* All mode: same indexes are interpreted as a highlight set; non-matching
* series still render. */
/** In Tooltip sync mode, limits which series are rendered in the receiver tooltip.
* null = no filtering; [] = no matches (tooltip hidden upstream); [...] = allowed indexes */
syncedSeriesIndexes?: number[] | null;
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
syncFilterMode?: SyncTooltipFilterMode;
}
export interface IRenderTooltipFooterArgs {
pinKey?: string;
isPinned: boolean;
dismiss: () => void;
}
export interface BaseTooltipProps {
id: string;
showTooltipHeader?: boolean;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => ReactNode;
timezone?: Timezone;
}
@@ -121,9 +106,4 @@ export interface TooltipContentItem {
tooltipValue: string;
color: string;
isActive: boolean;
/** Synced receiver series whose metric matches the source's focused series
* on the shared groupBy keys, in 'all' filter mode. List rendering uses this
* to apply the active highlight to matching rows while non-matching rows
* stay dimmed. */
isHighlighted?: boolean;
}

View File

@@ -18,7 +18,6 @@ import {
import {
DashboardCursorSync,
DEFAULT_PIN_TOOLTIP_KEY,
SyncTooltipFilterMode,
TooltipControllerContext,
TooltipControllerState,
TooltipLayoutInfo,
@@ -33,6 +32,7 @@ import {
import { Events } from 'constants/events';
import Styles from './TooltipPlugin.module.scss';
import { getAbsoluteUrl } from 'utils/basePath';
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
// the plot this avoids flicker when moving between nearby points.
@@ -199,14 +199,10 @@ export default function TooltipPlugin({
if (!controller.hoverActive || !plot) {
return null;
}
const filterMode =
syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
// In Filtered Tooltip sync mode, suppress the receiver tooltip entirely
// when no receiver series match the source panel's focused series. In
// All mode the tooltip still renders with every series visible.
// In Tooltip sync mode, suppress the receiver tooltip entirely when
// no receiver series match the source panel's focused series.
if (
syncTooltipWithDashboard &&
filterMode === SyncTooltipFilterMode.Filtered &&
controller.cursorDrivenBySync &&
Array.isArray(controller.syncedSeriesIndexes) &&
controller.syncedSeriesIndexes.length === 0
@@ -221,7 +217,6 @@ export default function TooltipPlugin({
dismiss: dismissTooltip,
viaSync: controller.cursorDrivenBySync,
syncedSeriesIndexes: controller.syncedSeriesIndexes,
syncFilterMode: filterMode,
});
}
@@ -309,7 +304,7 @@ export default function TooltipPlugin({
if (event.key === 'Escape') {
if (controller.pinned) {
logEvent(Events.TOOLTIP_UNPINNED, {
id: config.getId(),
path: getAbsoluteUrl(window.location.pathname),
});
dismissTooltip();
}
@@ -323,7 +318,7 @@ export default function TooltipPlugin({
// Toggle off: P pressed while already pinned.
if (controller.pinned) {
logEvent(Events.TOOLTIP_UNPINNED, {
id: config.getId(),
path: getAbsoluteUrl(window.location.pathname),
});
dismissTooltip();
return;
@@ -357,7 +352,7 @@ export default function TooltipPlugin({
controller.clickData = buildClickData(syntheticEvent, plot);
controller.pinned = true;
logEvent(Events.TOOLTIP_PINNED, {
id: config.getId(),
path: getAbsoluteUrl(window.location.pathname),
});
scheduleRender(true);
};

View File

@@ -2,33 +2,10 @@ import uPlot from 'uplot';
import type { ExtendedSeries } from '../../config/types';
import { syncCursorRegistry } from './syncCursorRegistry';
import {
SyncTooltipFilterMode,
type TooltipControllerState,
type TooltipSyncMetadata,
} from './types';
import type { TooltipControllerState, TooltipSyncMetadata } from './types';
/**
* Flattens per-query groupBys into a deduped set of dimension keys.
* A panel's effective groupBy is the union across all of its queries.
*/
function collectGroupByKeys(
groupByPerQuery: TooltipSyncMetadata['groupByPerQuery'],
): Set<string> {
const keys = new Set<string>();
if (!groupByPerQuery) {
return keys;
}
for (const groupBy of Object.values(groupByPerQuery)) {
for (const dim of groupBy) {
keys.add(dim.key);
}
}
return keys;
}
/**
* Returns the dimension keys present in both panels' groupBys.
* Returns the dimension keys present in both groupBy arrays.
* An empty result means no overlap — series highlighting should not run.
*
* exact [A, B] vs [A, B] → [A, B] one match
@@ -37,28 +14,24 @@ function collectGroupByKeys(
* partial [A, B] vs [B, C] → [B]
*/
function getCommonGroupByKeys(
a: TooltipSyncMetadata['groupByPerQuery'],
b: TooltipSyncMetadata['groupByPerQuery'],
a: TooltipSyncMetadata['groupBy'],
b: TooltipSyncMetadata['groupBy'],
): string[] {
const aKeys = collectGroupByKeys(a);
const bKeys = collectGroupByKeys(b);
if (aKeys.size === 0 || bKeys.size === 0) {
if (
!Array.isArray(a) ||
a.length === 0 ||
!Array.isArray(b) ||
b.length === 0
) {
return [];
}
const common: string[] = [];
aKeys.forEach((key) => {
if (bKeys.has(key)) {
common.push(key);
}
});
return common;
const bKeys = new Set(b.map((g) => g.key));
return a.filter((g) => bKeys.has(g.key)).map((g) => g.key);
}
/**
* Returns the 1-based indexes of every visible series whose metric matches
* sourceMetric on all commonKeys. Hidden series (toggled off in the legend)
* are excluded so the synced tooltip is suppressed when no visible series
* would match.
* Returns the 1-based indexes of every series whose metric matches
* sourceMetric on all commonKeys.
*/
function findMatchingSeriesIndexes(
series: uPlot.Series[],
@@ -66,7 +39,7 @@ function findMatchingSeriesIndexes(
commonKeys: string[],
): number[] {
return series.reduce<number[]>((acc, s, i) => {
if (i === 0 || s.show === false) {
if (i === 0) {
return acc;
}
const metric = (s as ExtendedSeries).metric;
@@ -103,15 +76,10 @@ function applySourceSync({
}
/**
* Computes receiver-side series filtering / highlighting for Tooltip sync.
*
* Returns the indexes that the tooltip render path should treat per
* `syncMetadata.filterMode`:
* - Filtered (default): null = no filter, [] = no matches (suppress tooltip),
* number[] = allowed indexes (show only these).
* - All: null = no highlight (show all), number[] = highlight set (show all,
* emphasize matching rows). Never returns [] in this mode so the synced
* tooltip is not suppressed when matches are missing.
* Returns:
* null no groupBy filtering configured or cursor off-chart (no-op for tooltip)
* [] groupBy configured but no receiver series match the source (hide synced tooltip)
* number[] 1-based indexes of matching receiver series (show only these)
*/
function applyReceiverSync({
uPlotInstance,
@@ -131,13 +99,8 @@ function applyReceiverSync({
yCrosshairEl.style.display =
sourceMetadata?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
const filterMode = syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
const noMatchResult: number[] | null =
filterMode === SyncTooltipFilterMode.All ? null : [];
if (commonKeys.length === 0) {
uPlotInstance.setSeries(null, { focus: false });
return [];
return null;
}
if ((uPlotInstance.cursor.left ?? -1) < 0) {
@@ -148,7 +111,7 @@ function applyReceiverSync({
const sourceSeriesMetric = syncCursorRegistry.getActiveSeriesMetric(syncKey);
if (sourceSeriesMetric == null) {
uPlotInstance.setSeries(null, { focus: false });
return noMatchResult;
return [];
}
const matchingIdxs = findMatchingSeriesIndexes(
@@ -159,7 +122,7 @@ function applyReceiverSync({
if (matchingIdxs.length === 0) {
uPlotInstance.setSeries(null, { focus: false });
return noMatchResult;
return [];
}
uPlotInstance.setSeries(matchingIdxs[0], { focus: true });
@@ -177,7 +140,7 @@ export function createSyncDisplayHook(
// groupBy on both panels is stable (set at config time). Recompute the
// intersection only when the source panel's groupBy reference changes.
let lastSourceGroupBy: TooltipSyncMetadata['groupByPerQuery'];
let lastSourceGroupBy: TooltipSyncMetadata['groupBy'];
let cachedCommonKeys: string[] = [];
return (u: uPlot): void => {
@@ -202,11 +165,11 @@ export function createSyncDisplayHook(
// inside applyReceiverSync.
const sourceMetadata = syncCursorRegistry.getMetadata(syncKey);
if (sourceMetadata?.groupByPerQuery !== lastSourceGroupBy) {
lastSourceGroupBy = sourceMetadata?.groupByPerQuery;
if (sourceMetadata?.groupBy !== lastSourceGroupBy) {
lastSourceGroupBy = sourceMetadata?.groupBy;
cachedCommonKeys = getCommonGroupByKeys(
sourceMetadata?.groupByPerQuery,
syncMetadata?.groupByPerQuery,
sourceMetadata?.groupBy,
syncMetadata?.groupBy,
);
}

View File

@@ -16,18 +16,9 @@ export const TOOLTIP_OFFSET = 10;
export const DEFAULT_PIN_TOOLTIP_KEY = 'p';
export enum DashboardCursorSync {
Crosshair = 'crosshair',
None = 'none',
Tooltip = 'tooltip',
}
/**
* Controls whether a synced tooltip filters series by groupBy intersection
* or shows every series with the matching ones highlighted.
*/
export enum SyncTooltipFilterMode {
Filtered = 'filtered',
All = 'all',
Crosshair,
None,
Tooltip,
}
export interface TooltipViewState {
@@ -49,8 +40,7 @@ export interface TooltipLayoutInfo {
export interface TooltipSyncMetadata {
yAxisUnit?: string;
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
filterMode?: SyncTooltipFilterMode;
groupBy?: BaseAutocompleteData[];
}
export interface TooltipPluginProps {

View File

@@ -1,152 +0,0 @@
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type uPlot from 'uplot';
import type { ExtendedSeries } from '../../config/types';
import { syncCursorRegistry } from '../TooltipPlugin/syncCursorRegistry';
import { createSyncDisplayHook } from '../TooltipPlugin/syncDisplayHook';
import type {
TooltipControllerState,
TooltipSyncMetadata,
} from '../TooltipPlugin/types';
const SYNC_KEY = 'test-sync';
function makeController(): TooltipControllerState {
return {
plot: null,
hoverActive: false,
isAnySeriesActive: false,
pinned: false,
clickData: null,
style: {},
horizontalOffset: 0,
verticalOffset: 0,
seriesIndexes: [],
focusedSeriesIndex: null,
syncedSeriesIndexes: null,
cursorDrivenBySync: false,
plotWithinViewport: true,
windowWidth: 1024,
windowHeight: 768,
pendingPinnedUpdate: false,
};
}
function makeFakePlot(
series: ExtendedSeries[],
cursorEvent: Record<string, unknown> | null = null,
): uPlot {
const root = document.createElement('div');
const yCrosshair = document.createElement('div');
yCrosshair.className = 'u-cursor-y';
root.appendChild(yCrosshair);
return {
root,
series,
cursor: { event: cursorEvent, left: 50 },
setSeries: jest.fn(),
} as unknown as uPlot;
}
const SERVICE_NAME_KEY: BaseAutocompleteData = {
key: 'service.name',
type: 'tag',
};
const groupByService: TooltipSyncMetadata = {
groupByPerQuery: { queryName: [SERVICE_NAME_KEY] },
};
function seedSourcePanel(activeMetric: Record<string, string>): void {
syncCursorRegistry.setMetadata(SYNC_KEY, groupByService);
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, activeMetric);
}
function makeReceiverSeries(
entries: { name: string; show?: boolean }[],
): ExtendedSeries[] {
return [
{} as ExtendedSeries,
...entries.map(
(e) =>
({
show: e.show ?? true,
metric: { 'service.name': e.name },
}) as unknown as ExtendedSeries,
),
];
}
describe('createSyncDisplayHook (receiver-side filtering)', () => {
beforeEach(() => {
syncCursorRegistry.setMetadata(SYNC_KEY, undefined);
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, null);
});
it('returns indexes of visible matching series only', () => {
seedSourcePanel({ 'service.name': 'flagd' });
const series = makeReceiverSeries([
{ name: 'flagd', show: true },
{ name: 'frontend', show: true },
{ name: 'flagd', show: true },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 3]);
});
it('treats all matching series being hidden as no match → empty array', () => {
seedSourcePanel({ 'service.name': 'frontendproxy' });
const series = makeReceiverSeries([
{ name: 'flagd', show: true },
{ name: 'frontendproxy', show: false },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
expect(plot.setSeries).toHaveBeenCalledWith(null, { focus: false });
});
it('excludes hidden series and keeps the visible matches', () => {
seedSourcePanel({ 'service.name': 'flagd' });
const series = makeReceiverSeries([
{ name: 'flagd', show: false },
{ name: 'frontend', show: true },
{ name: 'flagd', show: true },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([3]);
// Focuses the first visible match, not the hidden one at index 1.
expect(plot.setSeries).toHaveBeenCalledWith(3, { focus: true });
});
it('returns null (no filtering) when the hook runs on the source panel', () => {
const series = makeReceiverSeries([{ name: 'flagd', show: true }]);
// cursor.event != null marks this invocation as the source panel.
const plot = makeFakePlot(series, { type: 'mousemove' });
const controller = makeController();
controller.focusedSeriesIndex = 1;
(series[1] as ExtendedSeries).metric = { 'service.name': 'flagd' };
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toBeNull();
expect(syncCursorRegistry.getActiveSeriesMetric(SYNC_KEY)).toStrictEqual({
'service.name': 'flagd',
});
});
});

View File

@@ -0,0 +1,62 @@
/* eslint-disable */
//@ts-nocheck
import { memo } from 'react';
import ForceGraph2D from 'react-force-graph-2d';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { getGraphData, getTooltip, transformLabel } from './utils';
function ServiceMap({ fgRef, serviceMap }: any): JSX.Element {
const isDarkMode = useIsDarkMode();
const { nodes, links } = getGraphData(serviceMap, isDarkMode);
const graphData = { nodes, links };
let zoomLevel = 1;
return (
<ForceGraph2D
ref={fgRef}
cooldownTicks={100}
graphData={graphData}
linkLabel={getTooltip}
linkAutoColorBy={(d) => d.target}
linkDirectionalParticles="value"
linkDirectionalParticleSpeed={(d) => d.value}
nodeCanvasObject={(node, ctx) => {
const label = transformLabel(node.id, zoomLevel);
let { fontSize } = node;
fontSize = (fontSize * 3) / zoomLevel;
ctx.font = `${fontSize}px Roboto`;
const { width } = node;
ctx.fillStyle = node.color;
ctx.beginPath();
ctx.arc(node.x, node.y, width, 0, 2 * Math.PI, false);
ctx.fill();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = isDarkMode ? '#ffffff' : '#000000';
ctx.fillText(label, node.x, node.y);
}}
onLinkHover={(node) => {
const tooltip = document.querySelector('.graph-tooltip');
if (tooltip && node) {
tooltip.innerHTML = getTooltip(node);
}
}}
onZoom={(zoom) => {
zoomLevel = zoom.k;
}}
nodePointerAreaPaint={(node, color, ctx) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(node.x, node.y, 5, 0, 2 * Math.PI, false);
ctx.fill();
}}
/>
);
}
export default memo(ServiceMap);

View File

@@ -1,6 +1,6 @@
//@ts-nocheck
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
// eslint-disable-next-line no-restricted-imports
import { connect } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom';
@@ -16,9 +16,27 @@ import { AppState } from 'store/reducers';
import styled from 'styled-components';
import { GlobalTime } from 'types/actions/globalTime';
import Map from './components/Map/Map';
import Map from './Map';
const Container = styled.div``;
const Container = styled.div`
.force-graph-container {
overflow: scroll;
}
.force-graph-container .graph-tooltip {
background: black;
padding: 1px;
.keyval {
display: flex;
.key {
margin-right: 4px;
}
.val {
margin-left: auto;
}
}
}
`;
interface ServiceMapProps extends RouteComponentProps<any> {
serviceMap: ServiceMapStore;
@@ -31,11 +49,6 @@ interface ServiceMapProps extends RouteComponentProps<any> {
interface graphNode {
id: string;
group: number;
fontSize: number;
width: number;
color: string;
nodeVal: number;
name: string;
}
interface graphLink {
source: string;
@@ -51,6 +64,8 @@ export interface graphDataType {
}
function ServiceMap(props: ServiceMapProps): JSX.Element {
const fgRef = useRef();
const { getDetailedServiceMapItems, globalTime, serviceMap } = props;
const { queries } = useResourceAttribute();
@@ -63,6 +78,10 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
getDetailedServiceMapItems(globalTime, queries);
}, [globalTime, getDetailedServiceMapItems, queries]);
useEffect(() => {
fgRef.current && fgRef.current.d3Force('charge').strength(-400);
});
if (serviceMap.loading) {
return <Spinner size="large" tip="Loading..." />;
}
@@ -89,7 +108,7 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
}
/>
<Map serviceMap={serviceMap} />
<Map fgRef={fgRef} serviceMap={serviceMap} />
</div>
);
}

View File

@@ -1,89 +0,0 @@
import { BaseEdge, Edge, EdgeProps, getBezierPath } from '@xyflow/react';
import { getParticleAnimation } from '../Map/Map.constants';
export interface FlowEdgeData extends Record<string, unknown> {
p99: number;
callRate: number;
errorRate: number;
particleColor: string;
maxCallRate: number;
}
const DEFAULT_PARTICLE_COLOR = 'var(--accent-primary)';
function FlowEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
markerEnd,
data,
}: EdgeProps<Edge<FlowEdgeData>>): JSX.Element {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
// Particles flow callee -> caller (child -> parent), opposite to the edge's
// source -> target direction. Computing a reversed bezier instead of just
// playing the same path backward keeps the curve handles correct on both
// ends and avoids relying on `keyPoints`/`calcMode` quirks.
const [particlePath] = getBezierPath({
sourceX: targetX,
sourceY: targetY,
targetX: sourceX,
targetY: sourceY,
sourcePosition: targetPosition,
targetPosition: sourcePosition,
});
const callRate = data?.callRate ?? 0;
const maxCallRate = data?.maxCallRate ?? 0;
const { particleCount, duration } = getParticleAnimation(
callRate,
maxCallRate,
);
const fill = data?.particleColor || DEFAULT_PARTICLE_COLOR;
// Stagger each particle's `begin` so they're evenly distributed around the
// loop; the result is a continuous moving stream rather than synchronized
// dots stacking on top of each other.
const particles = Array.from({ length: particleCount }, (_, i) => {
const offset = (duration * i) / particleCount;
return (
<circle
key={`${id}-p${i}`}
className="flow-edge__particle"
r={2.75}
fill={fill}
pointerEvents="none"
>
<animateMotion
dur={`${duration}s`}
begin={`-${offset.toFixed(3)}s`}
repeatCount="indefinite"
path={particlePath}
rotate="auto"
/>
</circle>
);
});
return (
<>
<BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
{particles}
</>
);
}
export default FlowEdge;

View File

@@ -1,27 +0,0 @@
.tooltip {
position: fixed;
pointer-events: none;
z-index: 1000;
padding: 12px;
min-width: 160px;
font-size: 12px;
font-family: Inter, sans-serif;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
color: var(--popover-foreground);
background: var(--popover);
border: 1px solid var(--secondary-border);
}
.row {
display: flex;
justify-content: space-between;
}
.label {
margin-right: 8px;
}
.value {
margin-left: auto;
}

View File

@@ -1,39 +0,0 @@
import styles from './LinkTooltip.module.scss';
export interface LinkTooltipData {
p99: string | number;
callRate: string | number;
errorRate: string | number;
}
export interface LinkTooltipProps {
tooltip: LinkTooltipData;
x: number;
y: number;
}
const POINTER_OFFSET = 12;
function LinkTooltip({ tooltip, x, y }: LinkTooltipProps): JSX.Element {
return (
<div
className={styles.tooltip}
style={{ top: y + POINTER_OFFSET, left: x + POINTER_OFFSET }}
>
<div className={styles.row}>
<span className={styles.label}>P99 latency:</span>
<span className={styles.value}>{tooltip.p99}ms</span>
</div>
<div className={styles.row}>
<span className={styles.label}>Request:</span>
<span className={styles.value}>{tooltip.callRate}/sec</span>
</div>
<div className={styles.row}>
<span className={styles.label}>Error Rate:</span>
<span className={styles.value}>{tooltip.errorRate}%</span>
</div>
</div>
);
}
export default LinkTooltip;

View File

@@ -1,41 +0,0 @@
// Geometry of a service node as drawn on the map. The dagre layout uses a
// taller bounding box (label + circle) than the circle itself, so the outer
// height is exposed for the position centering calc.
export const NODE_DIAMETER = 64;
export const LABEL_HEIGHT = 18;
export const NODE_LABEL_GAP = 6;
export const NODE_OUTER_HEIGHT = NODE_DIAMETER + LABEL_HEIGHT + NODE_LABEL_GAP;
// Per-edge animated stream of dots. Speed and particle count scale with the
// edge's call rate *relative to the busiest edge in the current graph*, on a
// log10 ladder. The busiest edge always pegs the fastest/most-dense
// visualisation; the slowest gets a single drifting particle. This keeps the
// stream legible whether the busiest service handles 5 req/sec or 5k.
export const PARTICLE_FAST_SECS = 0.6;
export const PARTICLE_SLOW_SECS = 5;
export const MAX_PARTICLES = 8;
// Compute particle count + per-loop duration for an edge's call rate, scaled
// against the max call rate observed across the graph. Pure so it can be
// unit-tested without rendering the edge.
export function getParticleAnimation(
callRate: number,
maxCallRate: number,
): { particleCount: number; duration: number } {
if (callRate <= 0) {
return { particleCount: 0, duration: PARTICLE_SLOW_SECS };
}
// Defensive: if a stale/zero max sneaks in, treat this edge as the max so
// `factor` stays in [0, 1] rather than going to Infinity or NaN.
const effectiveMax = Math.max(maxCallRate, callRate);
const logRate = Math.log10(callRate + 1);
const logMax = Math.log10(effectiveMax + 1);
const factor = logMax > 0 ? logRate / logMax : 1;
const duration =
PARTICLE_SLOW_SECS - factor * (PARTICLE_SLOW_SECS - PARTICLE_FAST_SECS);
const particleCount = Math.max(
1,
Math.min(MAX_PARTICLES, Math.ceil(factor * MAX_PARTICLES)),
);
return { particleCount, duration };
}

View File

@@ -1,21 +0,0 @@
.container {
width: 100%;
height: calc(100vh - 124px);
position: relative;
border: 1px solid var(--l3-border);
border-radius: 8px;
overflow: hidden;
// ReactFlow defaults edge pointer-events to `visibleStroke`, which means
// our thin dashed line only captures hover on the painted dash segments.
// Force `stroke` on the wide invisible interaction path so the entire edge
// length is hoverable for the tooltip.
:global(.react-flow__edge-interaction) {
pointer-events: stroke;
cursor: default;
}
:global(.react-flow__edge) {
pointer-events: stroke;
}
}

View File

@@ -1,185 +0,0 @@
import '@xyflow/react/dist/style.css';
import { memo, useEffect, useMemo, useState } from 'react';
import {
Background,
BackgroundVariant,
Controls,
Edge,
Node,
ReactFlow,
useEdgesState,
useNodesState,
} from '@xyflow/react';
import { useIsDarkMode } from 'hooks/useDarkMode';
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
import { NODE_DIAMETER, NODE_OUTER_HEIGHT } from './Map.constants';
import styles from './Map.module.scss';
import ServiceNode, { ServiceNodeData } from '../ServiceNode/ServiceNode';
import LinkTooltip from '../LinkTooltip/LinkTooltip';
import {
computeNodePositions,
getEdgeColor,
getGraphData,
getLinkTooltip,
LinkTooltip as LinkTooltipData,
} from '../../utils';
const nodeTypes = { service: ServiceNode };
const edgeTypes = { flow: FlowEdge };
const PARTICLE_COLOR = 'var(--accent-primary)';
const BG_COLOR = 'var(--l2-background)';
const BASE_EDGE_STYLE = {
strokeWidth: 1.25,
strokeDasharray: '5 4',
};
interface HoverState {
tooltip: LinkTooltipData;
x: number;
y: number;
}
function ServiceMap({ serviceMap }: any): JSX.Element {
const isDarkMode = useIsDarkMode();
const [hovered, setHovered] = useState<HoverState | null>(null);
const { nodes: rawNodes, links } = useMemo(
() => getGraphData(serviceMap, isDarkMode),
[serviceMap, isDarkMode],
);
const positions = useMemo(
() => computeNodePositions(rawNodes, links),
[rawNodes, links],
);
const initialNodes: Node<ServiceNodeData>[] = useMemo(
() =>
rawNodes.map((node) => {
const center = positions[node.id] ?? { x: 0, y: 0 };
return {
id: node.id,
type: 'service',
// `position` is the top-left of the node bounding box; centre the
// circle on the simulated coordinate.
position: {
x: center.x - NODE_DIAMETER / 2,
y: center.y - NODE_OUTER_HEIGHT / 2,
},
data: { label: node.id, color: node.color, width: node.width },
draggable: true,
selectable: false,
};
}),
[rawNodes, positions],
);
// Particle visualisation is scaled relative to the busiest edge in the
// current graph, so each render of the edge layer needs the per-graph max.
const maxCallRate = useMemo(
() => links.reduce((max, link) => Math.max(max, link.callRate ?? 0), 0),
[links],
);
const initialEdges: Edge<FlowEdgeData>[] = useMemo(
() =>
links.map((link, i) => ({
id: `${link.source}->${link.target}-${i}`,
source: link.source,
target: link.target,
type: 'flow',
data: {
p99: link.p99,
callRate: link.callRate,
errorRate: link.errorRate,
particleColor: PARTICLE_COLOR,
maxCallRate,
},
// markerEnd: EDGE_MARKER,
// Stroke is hashed off the target so edges sharing a destination read
// as a single visual fan-in (matches the pre-rewrite behaviour).
style: { ...BASE_EDGE_STYLE, stroke: getEdgeColor(link.target) },
})),
[links, maxCallRate],
);
const [flowNodes, setFlowNodes, onNodesChange] =
useNodesState<Node<ServiceNodeData>>(initialNodes);
const [flowEdges, setFlowEdges, onEdgesChange] =
useEdgesState<Edge<FlowEdgeData>>(initialEdges);
// Reset internal node/edge state when the source graph changes (filters,
// time range, theme). User drag positions during a stable graph are kept.
useEffect(() => {
setFlowNodes(initialNodes);
}, [initialNodes, setFlowNodes]);
useEffect(() => {
setFlowEdges(initialEdges);
}, [initialEdges, setFlowEdges]);
const handleEdgeMouseEnter = (event: React.MouseEvent, edge: Edge): void => {
setHovered({
tooltip: getLinkTooltip(edge.data as any),
x: event.clientX,
y: event.clientY,
});
};
const handleEdgeMouseMove = (event: React.MouseEvent, edge: Edge): void => {
setHovered((prev) =>
prev
? { ...prev, x: event.clientX, y: event.clientY }
: {
tooltip: getLinkTooltip(edge.data as any),
x: event.clientX,
y: event.clientY,
},
);
};
const handleEdgeMouseLeave = (): void => {
setHovered(null);
};
return (
<div className={styles.container}>
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
minZoom={0.2}
maxZoom={4}
nodesDraggable
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
colorMode={isDarkMode ? 'dark' : 'light'}
onEdgeMouseEnter={handleEdgeMouseEnter}
onEdgeMouseMove={handleEdgeMouseMove}
onEdgeMouseLeave={handleEdgeMouseLeave}
>
<Background
bgColor={BG_COLOR}
variant={BackgroundVariant.Dots}
gap={24}
size={1}
/>
<Controls showInteractive={false} />
</ReactFlow>
{hovered && (
<LinkTooltip tooltip={hovered.tooltip} x={hovered.x} y={hovered.y} />
)}
</div>
);
}
export default memo(ServiceMap);

View File

@@ -1,41 +0,0 @@
.node {
display: flex;
flex-direction: column;
align-items: center;
overflow: visible;
}
.box {
display: flex;
align-items: center;
justify-content: center;
}
.circle {
border-radius: 50%;
border: 1px solid var(--l3-border);
box-sizing: border-box;
position: relative;
}
.handle {
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
border: none;
}
.label {
margin-top: 6px;
max-width: 120px;
font-size: 12px;
line-height: 14px;
text-align: center;
color: var(--l1-foreground);
font-family: Inter, sans-serif;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
}

View File

@@ -1,45 +0,0 @@
import { Handle, Node, NodeProps, Position } from '@xyflow/react';
import { NODE_DIAMETER } from '../Map/Map.constants';
import styles from './ServiceNode.module.scss';
export interface ServiceNodeData extends Record<string, unknown> {
label: string;
color: string;
// Radius in px, scaled from the node's call rate. Diameter on screen is 2*width;
// the outer box stays at NODE_DIAMETER so dagre's centred coordinates stay valid.
width: number;
}
function ServiceNode({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Element {
const diameter = data.width * 2;
return (
<div className={styles.node}>
<div
className={styles.box}
style={{ width: NODE_DIAMETER, height: NODE_DIAMETER }}
>
<div
className={styles.circle}
style={{
width: diameter,
height: diameter,
background: data.color,
}}
>
<Handle type="target" position={Position.Left} className={styles.handle} />
<Handle
type="source"
position={Position.Right}
className={styles.handle}
/>
</div>
</div>
<div className={styles.label} title={data.label}>
{data.label}
</div>
</div>
);
}
export default ServiceNode;

View File

@@ -1,185 +0,0 @@
import { Position } from '@xyflow/react';
import { render } from '@testing-library/react';
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
// Stub BaseEdge / getBezierPath so assertions don't depend on the internal
// path geometry — we only care that FlowEdge wires its inputs through and
// renders the right number of particles for the given call rate.
jest.mock('@xyflow/react', () => {
const actual = jest.requireActual('@xyflow/react');
return {
...actual,
BaseEdge: ({
id,
path,
style,
markerEnd,
}: {
id: string;
path: string;
style?: React.CSSProperties;
markerEnd?: string;
}): JSX.Element => (
<path
data-testid="base-edge"
data-id={id}
data-path={path}
data-marker-end={markerEnd ?? ''}
style={style}
/>
),
// Encode the inputs into the returned path so tests can distinguish
// between the forward edge path and the reversed particle path.
getBezierPath: ({
sourceX,
sourceY,
targetX,
targetY,
}: {
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
}): [string, number, number, number, number] => [
`M${sourceX},${sourceY} L${targetX},${targetY}`,
(sourceX + targetX) / 2,
(sourceY + targetY) / 2,
0,
0,
],
};
});
const baseEdgeProps = {
id: 'edge-1',
source: 'a',
target: 'b',
sourceX: 0,
sourceY: 0,
targetX: 100,
targetY: 0,
sourcePosition: Position.Right,
targetPosition: Position.Left,
style: { stroke: '#000' },
markerEnd: 'url(#arrow)',
} as const;
function renderEdge(data: FlowEdgeData | undefined): ReturnType<typeof render> {
return render(<FlowEdge {...(baseEdgeProps as any)} data={data} />);
}
const SAMPLE_DATA: FlowEdgeData = {
p99: 1000000,
callRate: 25,
errorRate: 0,
particleColor: 'rgb(0, 200, 0)',
maxCallRate: 1000,
};
describe('FlowEdge', () => {
it('forwards id, path, style, and markerEnd to BaseEdge', () => {
const { getByTestId } = renderEdge(SAMPLE_DATA);
const baseEdge = getByTestId('base-edge');
expect(baseEdge).toHaveAttribute('data-id', 'edge-1');
// BaseEdge gets the forward path (source -> target).
expect(baseEdge).toHaveAttribute('data-path', 'M0,0 L100,0');
expect(baseEdge).toHaveAttribute('data-marker-end', 'url(#arrow)');
expect(baseEdge).toHaveStyle({ stroke: '#000' });
});
it('renders no particles when callRate is zero', () => {
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 0 });
expect(container.querySelectorAll('circle')).toHaveLength(0);
});
it('renders no particles when data is missing', () => {
const { container } = renderEdge(undefined);
expect(container.querySelectorAll('circle')).toHaveLength(0);
});
it('renders multiple staggered particles for mid-range traffic', () => {
// callRate=25 against maxCallRate=1000:
// factor = log10(26) / log10(1001) ≈ 0.4716
// particleCount = ceil(0.4716 * 8) = 4
const { container } = renderEdge({
...SAMPLE_DATA,
callRate: 25,
maxCallRate: 1000,
});
const circles = container.querySelectorAll('circle');
expect(circles).toHaveLength(4);
// Each particle's animateMotion `begin` should be a distinct negative
// offset; identical offsets would stack particles on top of each other.
const begins = Array.from(container.querySelectorAll('animateMotion')).map(
(el) => el.getAttribute('begin'),
);
expect(new Set(begins).size).toBe(begins.length);
begins.forEach((begin) => {
expect(begin).toMatch(/^-\d+\.\d{3}s$/);
});
});
it('saturates at MAX_PARTICLES (8) when the edge is the busiest in the graph', () => {
// Relative scaling: whichever absolute rate is the max pegs at 8.
const { container } = renderEdge({
...SAMPLE_DATA,
callRate: 50,
maxCallRate: 50,
});
expect(container.querySelectorAll('circle')).toHaveLength(8);
});
it('uses data.particleColor as the particle fill', () => {
const { container } = renderEdge({
...SAMPLE_DATA,
callRate: 5,
particleColor: 'rgb(123, 45, 67)',
});
const circle = container.querySelector('circle');
expect(circle).toHaveAttribute('fill', 'rgb(123, 45, 67)');
});
it('falls back to the default particle color when particleColor is empty', () => {
const { container } = renderEdge({
...SAMPLE_DATA,
callRate: 5,
particleColor: '',
});
const circle = container.querySelector('circle');
expect(circle).toHaveAttribute('fill', 'var(--accent-primary)');
});
it('marks particles as non-interactive so they do not show a grab cursor', () => {
// Without pointer-events:none, react-flow's edge layer cursor (grab)
// cascades onto the SVG circles. Particles are decorative.
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 5 });
const circles = container.querySelectorAll('circle');
expect(circles.length).toBeGreaterThan(0);
circles.forEach((circle) => {
expect(circle).toHaveAttribute('pointer-events', 'none');
});
});
it('animates particles along the reversed path so they flow callee -> caller', () => {
// Edge goes (0,0) -> (100,0) but particles must travel back the other
// way to visualise the call-graph response direction.
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 5 });
const motions = container.querySelectorAll('animateMotion');
expect(motions.length).toBeGreaterThan(0);
motions.forEach((el) => {
expect(el).toHaveAttribute('path', 'M100,0 L0,0');
expect(el).toHaveAttribute('repeatCount', 'indefinite');
});
});
});

View File

@@ -1,58 +0,0 @@
import { render, screen } from '@testing-library/react';
import LinkTooltip, { LinkTooltipData } from '../LinkTooltip/LinkTooltip';
const baseTooltip: LinkTooltipData = {
p99: 12.34,
callRate: 5.6,
errorRate: 0.1,
};
describe('LinkTooltip', () => {
it('renders p99, request, and error rate rows with their suffixes', () => {
render(<LinkTooltip tooltip={baseTooltip} x={0} y={0} />);
expect(screen.getByText('P99 latency:')).toBeInTheDocument();
expect(screen.getByText('12.34ms')).toBeInTheDocument();
expect(screen.getByText('Request:')).toBeInTheDocument();
expect(screen.getByText('5.6/sec')).toBeInTheDocument();
expect(screen.getByText('Error Rate:')).toBeInTheDocument();
expect(screen.getByText('0.1%')).toBeInTheDocument();
});
it('renders string-typed metric values verbatim', () => {
render(
<LinkTooltip
tooltip={{ p99: '0', callRate: '0', errorRate: '0' }}
x={0}
y={0}
/>,
);
expect(screen.getByText('0ms')).toBeInTheDocument();
expect(screen.getByText('0/sec')).toBeInTheDocument();
expect(screen.getByText('0%')).toBeInTheDocument();
});
it('positions itself offset from the cursor coordinates', () => {
const { container } = render(
<LinkTooltip tooltip={baseTooltip} x={100} y={200} />,
);
// POINTER_OFFSET is 12 in the component; the tooltip should sit at
// (x + 12, y + 12) so it does not occlude the hovered edge segment.
const tooltip = container.firstChild as HTMLElement;
expect(tooltip).toHaveStyle({ top: '212px', left: '112px' });
});
it('handles negative coordinates without breaking the offset math', () => {
const { container } = render(
<LinkTooltip tooltip={baseTooltip} x={-50} y={-30} />,
);
const tooltip = container.firstChild as HTMLElement;
expect(tooltip).toHaveStyle({ top: '-18px', left: '-38px' });
});
});

View File

@@ -1,91 +0,0 @@
import {
getParticleAnimation,
MAX_PARTICLES,
PARTICLE_FAST_SECS,
PARTICLE_SLOW_SECS,
} from '../Map/Map.constants';
describe('getParticleAnimation', () => {
it('returns zero particles and the slow duration for non-positive call rates', () => {
expect(getParticleAnimation(0, 1000)).toStrictEqual({
particleCount: 0,
duration: PARTICLE_SLOW_SECS,
});
expect(getParticleAnimation(-5, 1000)).toStrictEqual({
particleCount: 0,
duration: PARTICLE_SLOW_SECS,
});
});
it('produces at least one particle for any positive call rate', () => {
expect(getParticleAnimation(0.1, 1000).particleCount).toBeGreaterThanOrEqual(
1,
);
expect(getParticleAnimation(1, 1000).particleCount).toBeGreaterThanOrEqual(1);
});
it('saturates at MAX_PARTICLES and PARTICLE_FAST_SECS when callRate equals max', () => {
// Whatever the absolute scale, the busiest edge should peg the
// visualisation — that's the point of the relative scaling.
const EPS = 1e-9;
[5, 50, 500, 5_000, 1_000_000].forEach((rate) => {
const { particleCount, duration } = getParticleAnimation(rate, rate);
expect(particleCount).toBe(MAX_PARTICLES);
expect(duration).toBeGreaterThanOrEqual(PARTICLE_FAST_SECS - EPS);
expect(duration).toBeLessThanOrEqual(PARTICLE_FAST_SECS + EPS);
});
});
it('caps particleCount at MAX_PARTICLES even if max is stale or zero', () => {
// Defensive: if max somehow lags behind callRate, factor still clamps to 1.
expect(getParticleAnimation(1000, 0).particleCount).toBe(MAX_PARTICLES);
expect(getParticleAnimation(1000, 100).particleCount).toBe(MAX_PARTICLES);
});
it('produces different particle counts for the same callRate at different scales', () => {
// 50 req/sec is "busy" in a 50-max graph but "trickle" in a 5k-max graph.
const busy = getParticleAnimation(50, 50);
const trickle = getParticleAnimation(50, 5000);
expect(busy.particleCount).toBeGreaterThan(trickle.particleCount);
expect(busy.duration).toBeLessThan(trickle.duration);
});
it('monotonically increases particle count as rate climbs toward max', () => {
const max = 5000;
const rates = [0.5, 5, 50, 500, max];
const counts = rates.map((r) => getParticleAnimation(r, max).particleCount);
for (let i = 1; i < counts.length; i += 1) {
expect(counts[i]).toBeGreaterThanOrEqual(counts[i - 1]);
}
});
it('monotonically decreases per-loop duration as rate climbs toward max', () => {
const max = 5000;
const rates = [0.5, 5, 50, 500, max];
const durations = rates.map((r) => getParticleAnimation(r, max).duration);
for (let i = 1; i < durations.length; i += 1) {
expect(durations[i]).toBeLessThanOrEqual(durations[i - 1]);
}
});
it('keeps duration bounded between PARTICLE_FAST_SECS and PARTICLE_SLOW_SECS', () => {
// At saturation the formula computes to PARTICLE_FAST_SECS up to
// floating-point error (~1e-16), so allow a small epsilon.
const EPS = 1e-9;
const cases: Array<[number, number]> = [
[0, 1000],
[0.01, 1000],
[1, 1000],
[10, 1000],
[100, 1000],
[1000, 1000],
[1000, 0], // defensive max
[1_000_000, 1_000_000],
];
cases.forEach(([rate, max]) => {
const { duration } = getParticleAnimation(rate, max);
expect(duration).toBeGreaterThanOrEqual(PARTICLE_FAST_SECS - EPS);
expect(duration).toBeLessThanOrEqual(PARTICLE_SLOW_SECS + EPS);
});
});
});

View File

@@ -1,128 +0,0 @@
import { render, screen } from '@testing-library/react';
import ServiceNode from '../ServiceNode/ServiceNode';
import { NODE_DIAMETER } from '../Map/Map.constants';
// `Handle` requires a ReactFlowProvider to mount. We don't exercise its
// connection logic from this component, so a stub keeps the test isolated to
// ServiceNode's own rendering responsibilities.
jest.mock('@xyflow/react', () => {
const actual = jest.requireActual('@xyflow/react');
return {
...actual,
Handle: ({
type,
position,
}: {
type: string;
position: string;
}): JSX.Element => (
<div data-testid={`handle-${type}`} data-position={position} />
),
};
});
const baseNodeProps = {
id: 'frontend',
type: 'service',
dragging: false,
isConnectable: true,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
zIndex: 0,
selectable: false,
deletable: false,
draggable: true,
selected: false,
} as const;
function renderNode(data: {
label: string;
color: string;
width: number;
}): ReturnType<typeof render> {
return render(<ServiceNode {...(baseNodeProps as any)} data={data} />);
}
describe('ServiceNode', () => {
it('renders the label text from data', () => {
renderNode({ label: 'checkout-service', color: 'red', width: 15 });
expect(screen.getByText('checkout-service')).toBeInTheDocument();
});
it('exposes the label as a title attribute for full-name hover-disclosure', () => {
// The visible label is truncated for layout, so the full service name is
// surfaced via title — assert the attribute round-trips data.label.
renderNode({
label: 'a-very-long-service-name-that-truncates',
color: 'red',
width: 15,
});
const label = screen.getByText('a-very-long-service-name-that-truncates');
expect(label).toHaveAttribute(
'title',
'a-very-long-service-name-that-truncates',
);
});
it('renders an outer box at the configured layout diameter', () => {
// The box is fixed-size so the dagre-laid-out (centred) coordinates stay
// valid regardless of the inner circle's call-rate-driven size.
const { container } = renderNode({
label: 'frontend',
color: 'rgb(255, 0, 0)',
width: 15,
});
const wrapper = container.firstChild as HTMLElement;
const box = wrapper.firstChild as HTMLElement;
expect(box).toHaveStyle({
width: `${NODE_DIAMETER}px`,
height: `${NODE_DIAMETER}px`,
});
});
it('sizes the inner circle to 2 * data.width and applies data.color', () => {
// data.width is a radius scaled from call rate; on-screen diameter is 2x.
const { container } = renderNode({
label: 'frontend',
color: 'rgb(255, 0, 0)',
width: 15,
});
const wrapper = container.firstChild as HTMLElement;
const box = wrapper.firstChild as HTMLElement;
const circle = box.firstChild as HTMLElement;
expect(circle).toHaveStyle({
background: 'rgb(255, 0, 0)',
width: '30px',
height: '30px',
});
});
it('renders the smallest circle when width is at the floor', () => {
// Source-only / low-traffic nodes hit MIN_WIDTH (22) → 44px diameter.
const { container } = renderNode({
label: 'loadgenerator',
color: 'green',
width: 22,
});
const wrapper = container.firstChild as HTMLElement;
const box = wrapper.firstChild as HTMLElement;
const circle = box.firstChild as HTMLElement;
expect(circle).toHaveStyle({ width: '44px', height: '44px' });
});
it('renders a target handle on the left and a source handle on the right', () => {
renderNode({ label: 'frontend', color: 'red', width: 15 });
const target = screen.getByTestId('handle-target');
const source = screen.getByTestId('handle-source');
expect(target).toHaveAttribute('data-position', 'left');
expect(source).toHaveAttribute('data-position', 'right');
});
});

View File

@@ -1,8 +1,5 @@
//@ts-nocheck
import dagre from '@dagrejs/dagre';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
cloneDeep,
find,
@@ -15,14 +12,8 @@ import {
import { graphDataType } from './ServiceMap';
// Radius in px. The original force-graph render implicitly applied a
// `sqrt(nodeVal) * nodeRelSize` curve, which compressed the visible range
// even though the underlying values spanned 1020. We keep that compression
// here (smaller min/max ratio than the raw spec) so low-traffic / source-only
// nodes stay readable. The max matches NODE_DIAMETER/2 so the busiest node
// fills the layout box.
const MIN_WIDTH = 22;
const MAX_WIDTH = 32;
const MIN_WIDTH = 10;
const MAX_WIDTH = 20;
const DEFAULT_FONT_SIZE = 6;
export const getDimensions = (
@@ -41,7 +32,7 @@ export const getDimensions = (
};
};
export const getGraphData = (serviceMap, _isDarkMode): graphDataType => {
export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
const { items } = serviceMap;
const services = Object.values(groupBy(items, 'child')).map((e) => {
return {
@@ -71,13 +62,9 @@ export const getGraphData = (serviceMap, _isDarkMode): graphDataType => {
const uniqParent = uniqBy(cloneDeep(items), 'parent').map((e) => e.parent);
const uniqChild = uniqBy(cloneDeep(items), 'child').map((e) => e.child);
const uniqNodes = uniq([...uniqParent, ...uniqChild]);
// Semantic tokens auto-flip with theme; passed as CSS variable strings so
// the consuming component can apply them directly via `style.background`.
const HEALTHY_COLOR = 'var(--l3-background)';
const ERROR_COLOR = 'var(--danger-background)';
const nodes = uniqNodes.map((node, i) => {
const service = find(services, (service) => service.serviceName === node);
let color = HEALTHY_COLOR;
let color = isDarkMode ? '#7CA568' : '#D5F2BB';
if (!service) {
return {
id: node,
@@ -90,7 +77,7 @@ export const getGraphData = (serviceMap, _isDarkMode): graphDataType => {
};
}
if (service.errorRate > 0) {
color = ERROR_COLOR;
color = isDarkMode ? '#DB836E' : '#F98989';
}
const { fontSize, width } = getDimensions(service.callRate, highestCallRate);
return {
@@ -109,6 +96,20 @@ export const getGraphData = (serviceMap, _isDarkMode): graphDataType => {
};
};
export const getZoomPx = (): number => {
const { width } = window.screen;
if (width < 1400) {
return 190;
}
if (width > 1400 && width < 1700) {
return 380;
}
if (width > 1700) {
return 470;
}
return 190;
};
const getRound2DigitsAfterDecimal = (num: number): number => {
if (num === 0) {
return 0;
@@ -116,28 +117,27 @@ const getRound2DigitsAfterDecimal = (num: number): number => {
return num.toFixed(20).match(/^-?\d*\.?0*\d{0,2}/)[0];
};
export interface LinkTooltip {
p99: string | number;
callRate: string | number;
errorRate: string | number;
}
export const getLinkTooltip = (link: {
export const getTooltip = (link: {
p99: number;
errorRate: number;
callRate: number;
}): LinkTooltip => ({
p99: getRound2DigitsAfterDecimal(link.p99 / 1000000),
callRate: getRound2DigitsAfterDecimal(link.callRate),
errorRate: getRound2DigitsAfterDecimal(link.errorRate),
});
// Edges share a color with every other edge pointing at the same destination
// service (mirrors the original `linkAutoColorBy={(d) => d.target}` behaviour).
// Hashing onto the existing chart palette keeps it visually consistent with
// the rest of the app and stable across renders for a given target id.
export const getEdgeColor = (targetId: string): string =>
generateColor(targetId, themeColors.chartcolors);
id: string;
}): string => {
return `<div style="color:#333333;padding:12px;background: white;border-radius: 2px;">
<div class="keyval">
<div class="key">P99 latency:</div>
<div class="val">${getRound2DigitsAfterDecimal(link.p99 / 1000000)}ms</div>
</div>
<div class="keyval">
<div class="key">Request:</div>
<div class="val">${getRound2DigitsAfterDecimal(link.callRate)}/sec</div>
</div>
<div class="keyval">
<div class="key">Error Rate:</div>
<div class="val">${getRound2DigitsAfterDecimal(link.errorRate)}%</div>
</div>
</div>`;
};
export const transformLabel = (label: string, zoomLevel: number): string => {
//? 13 is the minimum label length. Scaling factor of 0.9 which is slightly less than 1
@@ -150,51 +150,3 @@ export const transformLabel = (label: string, zoomLevel: number): string => {
}
return label;
};
// Layered DAG layout via dagre. For service maps the data flows
// caller -> callee, so a left-to-right rank direction reads naturally and
// minimises edge crossings vs. a force-directed simulation.
//
// `nodeBoxWidth` reserves space for the label rendered below each circle —
// the visible label can be up to ~120px wide, so dagre needs to know that
// horizontally adjacent ranks must keep that distance.
export const computeNodePositions = (
nodes: { id: string }[],
links: { source: string; target: string }[],
nodeBoxWidth = 130,
nodeBoxHeight = 100,
): Record<string, { x: number; y: number }> => {
const result: Record<string, { x: number; y: number }> = {};
if (nodes.length === 0) {
return result;
}
const g = new dagre.graphlib.Graph({ multigraph: true, compound: false });
g.setGraph({
rankdir: 'LR',
nodesep: 40,
ranksep: 90,
marginx: 40,
marginy: 40,
});
g.setDefaultEdgeLabel(() => ({}));
nodes.forEach((node) => {
g.setNode(node.id, { width: nodeBoxWidth, height: nodeBoxHeight });
});
links.forEach((link, i) => {
// `name` makes parallel edges (same source+target, different metrics)
// safe under multigraph mode.
g.setEdge(link.source, link.target, {}, `${link.source}-${link.target}-${i}`);
});
dagre.layout(g);
nodes.forEach((node) => {
const laidOut = g.node(node.id);
if (laidOut) {
result[node.id] = { x: laidOut.x, y: laidOut.y };
}
});
return result;
};

View File

@@ -2953,18 +2953,6 @@
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz#b6c75a56a1947cc916ea058772d666a2c8932f31"
integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==
"@dagrejs/dagre@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@dagrejs/dagre/-/dagre-3.0.0.tgz#543f20188f7494db0f45d634f7b3760747f87f23"
integrity sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==
dependencies:
"@dagrejs/graphlib" "4.0.1"
"@dagrejs/graphlib@4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-4.0.1.tgz#a9cf907cc5ddf9140a64360ad487766f17d1ee36"
integrity sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==
"@date-fns/tz@^1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.4.1.tgz#2d905f282304630e07bef6d02d2e7dbf3f0cc4e4"
@@ -6014,6 +6002,11 @@
resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz"
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==
"@tweenjs/tween.js@18 - 25":
version "25.0.0"
resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz#7266baebcc3affe62a3a54318a3ea82d904cd0b9"
integrity sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==
"@tybys/wasm-util@^0.10.0", "@tybys/wasm-util@^0.10.1":
version "0.10.1"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
@@ -6131,13 +6124,6 @@
resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz#006b7bd838baec1511270cb900bf4fc377bbbf41"
integrity sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==
"@types/d3-drag@^3.0.7":
version "3.0.7"
resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02"
integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==
dependencies:
"@types/d3-selection" "*"
"@types/d3-format@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.1.tgz#194f1317a499edd7e58766f96735bdc0216bb89d"
@@ -6155,13 +6141,6 @@
resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-1.1.11.tgz#c3bd70d025621f73cb3319e97e08ae4c9051c791"
integrity sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA==
"@types/d3-interpolate@*", "@types/d3-interpolate@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
dependencies:
"@types/d3-color" "*"
"@types/d3-interpolate@3.0.1", "@types/d3-interpolate@^3.0.0":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc"
@@ -6181,11 +6160,6 @@
dependencies:
"@types/d3-time" "*"
"@types/d3-selection@*", "@types/d3-selection@^3.0.10":
version "3.0.11"
resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3"
integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==
"@types/d3-shape@^1.3.1":
version "1.3.12"
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.12.tgz#8f2f9f7a12e631ce6700d6d55b84795ce2c8b259"
@@ -6208,21 +6182,6 @@
resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819"
integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==
"@types/d3-transition@^3.0.8":
version "3.0.9"
resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706"
integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==
dependencies:
"@types/d3-selection" "*"
"@types/d3-zoom@^3.0.8":
version "3.0.8"
resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b"
integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==
dependencies:
"@types/d3-interpolate" "*"
"@types/d3-selection" "*"
"@types/debug@^4.0.0":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.8.tgz#cef723a5d0a90990313faec2d1e22aee5eecb317"
@@ -7113,30 +7072,6 @@
resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz"
integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==
"@xyflow/react@12.10.2":
version "12.10.2"
resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.10.2.tgz#40f6d71944f674f0ffbb83c660f9473018adbe61"
integrity sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==
dependencies:
"@xyflow/system" "0.0.76"
classcat "^5.0.3"
zustand "^4.4.0"
"@xyflow/system@0.0.76":
version "0.0.76"
resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.76.tgz#57da5e4d230cdbec56548a6d5eec115f22858259"
integrity sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==
dependencies:
"@types/d3-drag" "^3.0.7"
"@types/d3-interpolate" "^3.0.4"
"@types/d3-selection" "^3.0.10"
"@types/d3-transition" "^3.0.8"
"@types/d3-zoom" "^3.0.8"
d3-drag "^3.0.0"
d3-interpolate "^3.0.1"
d3-selection "^3.0.0"
d3-zoom "^3.0.0"
"@zxing/text-encoding@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b"
@@ -7162,6 +7097,11 @@ abort-controller@^3.0.0:
dependencies:
event-target-shim "^5.0.0"
accessor-fn@1:
version "1.4.1"
resolved "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.4.1.tgz"
integrity sha512-P7yNKfmpuWLUwiRVk9RkRIPGjngemjZ7yANc0DL7otgDqEIWkEByMhShzfgQ5ZwCPEUmba4v1kOqCdGhpzY3ew==
acorn-globals@^7.0.0:
version "7.0.1"
resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3"
@@ -8059,6 +7999,11 @@ better-xlsx@^0.7.5:
jszip "^3.2.2"
kind-of "^6.0.3"
"bezier-js@3 - 6":
version "6.1.3"
resolved "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.3.tgz"
integrity sha512-VPFvkyO98oCJ1Tsi+bFBrKEWLdefAj4DJVaWp3xTEsdCbunC7Pt/nTeIgu/UdskBNcmHv8TOfsgdMZb1GsICmg==
big-integer@^1.6.16:
version "1.6.51"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
@@ -8328,6 +8273,13 @@ caniuse-lite@^1.0.30001759:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz#0279c498e862efb067938bba0a0aabafe8d0b730"
integrity sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==
canvas-color-tracker@^1.3:
version "1.3.2"
resolved "https://registry.yarnpkg.com/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz#b924cf94b33441b82692938fca5b936be971a46d"
integrity sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==
dependencies:
tinycolor2 "^1.6.0"
ccount@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
@@ -8469,11 +8421,6 @@ class-variance-authority@^0.7.0:
dependencies:
clsx "^2.1.1"
classcat@^5.0.3:
version "5.0.5"
resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77"
integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==
classnames@2.3.2, classnames@2.x, classnames@^2.2.1, classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2:
version "2.3.2"
resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz"
@@ -9108,7 +9055,7 @@ csstype@^3.1.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
"d3-array@2 - 3", "d3-array@2.10.0 - 3":
"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3":
version "3.2.3"
resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.3.tgz"
integrity sha512-JRHwbQQ84XuAESWhvIPaUV4/1UYTBOLiOPGWqgFDHZS1D5QN9c57FbH3QpEnQMYiOXNzKUQyGTZf+EVO7RT5TQ==
@@ -9134,6 +9081,11 @@ d3-array@^1.2.0:
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
d3-binarytree@1:
version "1.0.2"
resolved "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz"
integrity sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==
"d3-color@1 - 3", d3-color@3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz"
@@ -9151,7 +9103,7 @@ d3-delaunay@6.0.2:
resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz"
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
"d3-drag@2 - 3", d3-drag@^3.0.0:
"d3-drag@2 - 3":
version "3.0.0"
resolved "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz"
integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
@@ -9164,6 +9116,17 @@ d3-delaunay@6.0.2:
resolved "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz"
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
"d3-force-3d@2 - 3":
version "3.0.5"
resolved "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.5.tgz"
integrity sha512-tdwhAhoTYZY/a6eo9nR7HP3xSW/C6XvJTbeRpR92nlPzH6OiE+4MliN9feuSFd0tPtEUo+191qOhCTWx3NYifg==
dependencies:
d3-binarytree "1"
d3-dispatch "1 - 3"
d3-octree "1"
d3-quadtree "1 - 3"
d3-timer "1 - 3"
"d3-format@1 - 3", d3-format@3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz"
@@ -9186,13 +9149,18 @@ d3-hierarchy@^1.1.4:
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3.0.1, d3-interpolate@^3.0.1:
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
dependencies:
d3-color "1 - 3"
d3-octree@1:
version "1.0.2"
resolved "https://registry.npmjs.org/d3-octree/-/d3-octree-1.0.2.tgz"
integrity sha512-Qxg4oirJrNXauiuC94uKMbgxwnhdda9xRLl9ihq45srlJ4Ga3CSgqGcAL8iW7N5CIv4Oz8x3E734ulxyvHPvwA==
d3-path@1, d3-path@^1.0.5:
version "1.0.9"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
@@ -9203,7 +9171,20 @@ d3-polygon@^1.0.3:
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
d3-scale@4.0.2:
"d3-quadtree@1 - 3":
version "3.0.1"
resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz"
integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==
"d3-scale-chromatic@1 - 3":
version "3.0.0"
resolved "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz"
integrity sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==
dependencies:
d3-color "1 - 3"
d3-interpolate "1 - 3"
"d3-scale@1 - 4", d3-scale@4.0.2:
version "4.0.2"
resolved "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz"
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
@@ -9214,9 +9195,9 @@ d3-scale@4.0.2:
d3-time "2.1.1 - 3"
d3-time-format "2 - 4"
"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
"d3-selection@2 - 3", d3-selection@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz"
integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
d3-shape@^1.0.6, d3-shape@^1.2.0:
@@ -9256,9 +9237,9 @@ d3-shape@^1.0.6, d3-shape@^1.2.0:
d3-interpolate "1 - 3"
d3-timer "1 - 3"
d3-zoom@^3.0.0:
"d3-zoom@2 - 3":
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
resolved "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz"
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
dependencies:
d3-dispatch "1 - 3"
@@ -10479,6 +10460,15 @@ flatted@^3.4.2:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
float-tooltip@^1.7:
version "1.7.5"
resolved "https://registry.yarnpkg.com/float-tooltip/-/float-tooltip-1.7.5.tgz#7083bf78f0de5a97f9c2d6aa8e90d2139f34047f"
integrity sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==
dependencies:
d3-selection "2 - 3"
kapsule "^1.16"
preact "10"
flubber@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/flubber/-/flubber-0.4.2.tgz#14452d4a838cc3b9f2fb6175da94e35acd55fbaa"
@@ -10515,6 +10505,27 @@ for-each@^0.3.5:
dependencies:
is-callable "^1.2.7"
force-graph@^1.51:
version "1.51.1"
resolved "https://registry.yarnpkg.com/force-graph/-/force-graph-1.51.1.tgz#c967249bf6ad2cb4a3ba89ed4c6d79895bd70fe1"
integrity sha512-uEEX8iRzgq1IKRISOw6RrB2RLMhcI25xznQYrCTVvxZHZZ+A2jH6qIolYuwavVxAMi64pFp2yZm4KFVdD993cg==
dependencies:
"@tweenjs/tween.js" "18 - 25"
accessor-fn "1"
bezier-js "3 - 6"
canvas-color-tracker "^1.3"
d3-array "1 - 3"
d3-drag "2 - 3"
d3-force-3d "2 - 3"
d3-scale "1 - 4"
d3-scale-chromatic "1 - 3"
d3-selection "2 - 3"
d3-zoom "2 - 3"
float-tooltip "^1.7"
index-array-by "1"
kapsule "^1.16"
lodash-es "4"
foreground-child@^3.1.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
@@ -11662,6 +11673,11 @@ indent-string@^4.0.0:
resolved "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz"
integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
index-array-by@1:
version "1.4.1"
resolved "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.1.tgz"
integrity sha512-Zu6THdrxQdyTuT2uA5FjUoBEsFHPzHcPIj18FszN6yXKHxSfGcR4TPLabfuT//E25q1Igyx9xta2WMvD/x9P/g==
inflected@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/inflected/-/inflected-2.1.0.tgz#2816ac17a570bbbc8303ca05bca8bf9b3f959687"
@@ -12367,6 +12383,11 @@ jake@^10.8.5:
filelist "^1.0.4"
picocolors "^1.1.1"
jerrypick@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.1.tgz"
integrity sha512-XTtedPYEyVp4t6hJrXuRKr/jHj8SC4z+4K0b396PMkov6muL+i8IIamJIvZWe3jUspgIJak0P+BaWKawMYNBLg==
jest-changed-files@30.2.0:
version "30.2.0"
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.2.0.tgz#602266e478ed554e1e1469944faa7efd37cee61c"
@@ -13082,6 +13103,13 @@ junk@^3.1.0:
resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==
kapsule@^1.16:
version "1.16.3"
resolved "https://registry.yarnpkg.com/kapsule/-/kapsule-1.16.3.tgz#5684ed89838b6658b30d0f2cc056dffc3ba68c30"
integrity sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==
dependencies:
lodash-es "4"
keyv@^4.0.0:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@@ -13306,7 +13334,7 @@ locate-path@^7.1.0:
dependencies:
p-locate "^6.0.0"
lodash-es@^4.17.21:
lodash-es@4, lodash-es@^4.17.21:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
@@ -15617,6 +15645,11 @@ powershell-utils@^0.1.0:
resolved "https://registry.yarnpkg.com/powershell-utils/-/powershell-utils-0.1.0.tgz#5a42c9a824fb4f2f251ccb41aaae73314f5d6ac2"
integrity sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==
preact@10:
version "10.28.4"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.4.tgz#8ffab01c5c0590535bdaecdd548801f44c6e483a"
integrity sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==
preact@^10.19.3:
version "10.22.0"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.22.0.tgz#a50f38006ae438d255e2631cbdaf7488e6dd4e16"
@@ -15678,7 +15711,7 @@ progress@^2.0.3:
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
prop-types@15.8.1, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@15, prop-types@15.8.1, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -16302,6 +16335,15 @@ react-fast-compare@^3.2.0:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
react-force-graph-2d@^1.29.1:
version "1.29.1"
resolved "https://registry.yarnpkg.com/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz#a0784d4387b12b28e2b552058ec09d092b4e8cda"
integrity sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==
dependencies:
force-graph "^1.51"
prop-types "15"
react-kapsule "^2.5"
react-full-screen@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/react-full-screen/-/react-full-screen-1.1.1.tgz#b707d56891015a71c503a65dbab3086d75be97d7"
@@ -16371,6 +16413,13 @@ react-is@^18.3.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
react-kapsule@^2.5:
version "2.5.7"
resolved "https://registry.yarnpkg.com/react-kapsule/-/react-kapsule-2.5.7.tgz#dcd957ae8e897ff48055fc8ff48ed04ebe3c5bd2"
integrity sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==
dependencies:
jerrypick "^1.1.1"
react-lottie@1.2.10:
version "1.2.10"
resolved "https://registry.yarnpkg.com/react-lottie/-/react-lottie-1.2.10.tgz#399f78a448a7833b2380d74fc489ecf15f8d18c7"
@@ -18440,7 +18489,7 @@ tiny-warning@^1.0.0:
resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
tinycolor2@1.6.0:
tinycolor2@1.6.0, tinycolor2@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
@@ -19145,7 +19194,7 @@ use-sidecar@^1.1.3:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@1.6.0, use-sync-external-store@^1.2.2:
use-sync-external-store@1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
@@ -19788,13 +19837,6 @@ zustand@5.0.11:
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.11.tgz#99f912e590de1ca9ce6c6d1cab6cdb1f034ab494"
integrity sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==
zustand@^4.4.0:
version "4.5.7"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55"
integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
dependencies:
use-sync-external-store "^1.2.2"
zwitch@^2.0.0, zwitch@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"

1
go.mod
View File

@@ -88,6 +88,7 @@ require (
gonum.org/v1/gonum v0.17.0
google.golang.org/api v0.265.0
google.golang.org/protobuf v1.36.11
gopkg.in/evanphx/json-patch.v4 v4.13.0
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.35.2

View File

@@ -13,6 +13,165 @@ import (
)
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authZ.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
ID: "CreateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Create dashboard (v2)",
Description: "This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.",
Request: new(dashboardtypes.PostableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authZ.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
ID: "GetDashboardV2",
Tags: []string{"dashboard"},
Summary: "Get dashboard (v2)",
Description: "This endpoint returns a v2-shape dashboard with its tags and public sharing config (if any).",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authZ.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
ID: "UpdateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Update dashboard (v2)",
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
Request: new(dashboardtypes.UpdateableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authZ.EditAccess(provider.dashboardHandler.PatchV2), handler.OpenAPIDef{
ID: "PatchDashboardV2",
Tags: []string{"dashboard"},
Summary: "Patch dashboard (v2)",
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Locked dashboards are rejected.",
Request: new(dashboardtypes.JSONPatchDocument),
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
// but our OpenAPI generator only reflects schemas for content types it
// understands (application/json, form-urlencoded, multipart) — anything
// else degrades to `type: string`. Declaring application/json here keeps
// the array-of-ops schema visible to spec consumers; the runtime decoder
// parses JSON regardless of the request's actual Content-Type header.
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authZ.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
ID: "LockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Lock dashboard (v2)",
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authZ.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
ID: "UnlockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unlock dashboard (v2)",
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/public", handler.New(provider.authZ.AdminAccess(provider.dashboardHandler.CreatePublicV2), handler.OpenAPIDef{
ID: "CreatePublicDashboardV2",
Tags: []string{"dashboard"},
Summary: "Make a dashboard v2 public",
Description: "This endpoint creates the public sharing config for a v2 dashboard and returns the dashboard with the new public config attached. Lock state does not gate this endpoint.",
Request: new(dashboardtypes.PostablePublicDashboard),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authZ.EditAccess(provider.dashboardHandler.DeleteV2), handler.OpenAPIDef{
ID: "DeleteDashboardV2",
Tags: []string{"dashboard"},
Summary: "Delete dashboard (v2)",
Description: "This endpoint soft-deletes a v2-shape dashboard. Locked dashboards are rejected. Hard deletion happens later via the purge cron.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/public", handler.New(provider.authZ.AdminAccess(provider.dashboardHandler.UpdatePublicV2), handler.OpenAPIDef{
ID: "UpdatePublicDashboardV2",
Tags: []string{"dashboard"},
Summary: "Update public sharing config for a dashboard v2",
Description: "This endpoint updates the public sharing config (time range settings) of an already-public v2 dashboard. Lock state does not gate this endpoint.",
Request: new(dashboardtypes.UpdatablePublicDashboard),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authZ.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},

View File

@@ -52,6 +52,26 @@ type Module interface {
statsreporter.StatsCollector
authz.RegisterTypeable
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error)
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error)
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
CreatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, postable dashboardtypes.PostablePublicDashboard) (*dashboardtypes.DashboardV2, error)
UpdatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable dashboardtypes.UpdatablePublicDashboard) (*dashboardtypes.DashboardV2, error)
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, deletedBy string) error
}
type Handler interface {
@@ -74,4 +94,25 @@ type Handler interface {
LockUnlock(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
CreateV2(http.ResponseWriter, *http.Request)
GetV2(http.ResponseWriter, *http.Request)
UpdateV2(http.ResponseWriter, *http.Request)
PatchV2(http.ResponseWriter, *http.Request)
LockV2(http.ResponseWriter, *http.Request)
UnlockV2(http.ResponseWriter, *http.Request)
CreatePublicV2(http.ResponseWriter, *http.Request)
UpdatePublicV2(http.ResponseWriter, *http.Request)
DeleteV2(http.ResponseWriter, *http.Request)
}

View File

@@ -0,0 +1,46 @@
package dashboardpurger
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
)
type Config struct {
// Interval between successive purge passes.
Interval time.Duration `mapstructure:"interval"`
// BatchSize is the maximum number of dashboards hard-deleted per pass.
// Caps the size of the IN(...) clause and bounds tx duration.
BatchSize int `mapstructure:"batch_size"`
// Retention is how long a soft-deleted dashboard sticks around before
// becoming eligible for hard deletion.
Retention time.Duration `mapstructure:"retention"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("dashboardpurger"), newConfig)
}
func newConfig() factory.Config {
return &Config{
Interval: 1 * time.Hour,
BatchSize: 100,
Retention: 7 * 24 * time.Hour,
}
}
func (c Config) Validate() error {
if c.Interval <= 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "interval must be positive")
}
if c.BatchSize <= 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "batch_size must be positive")
}
if c.Retention < 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "retention must not be negative")
}
return nil
}

View File

@@ -0,0 +1,79 @@
package dashboardpurger
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
type Purger interface {
factory.Service
}
type purger struct {
config Config
settings factory.ScopedProviderSettings
store dashboardtypes.Store
stopC chan struct{}
}
func NewFactory(store dashboardtypes.Store) factory.ProviderFactory[Purger, Config] {
return factory.NewProviderFactory(factory.MustNewName("dashboardpurger"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (Purger, error) {
return New(ctx, providerSettings, config, store)
})
}
func New(_ context.Context, providerSettings factory.ProviderSettings, config Config, store dashboardtypes.Store) (Purger, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/dashboard/dashboardpurger")
return &purger{
config: config,
settings: settings,
store: store,
stopC: make(chan struct{}),
}, nil
}
func (p *purger) Start(ctx context.Context) error {
ticker := time.NewTicker(p.config.Interval)
defer ticker.Stop()
for {
select {
case <-p.stopC:
return nil
case <-ticker.C:
ctx, span := p.settings.Tracer().Start(ctx, "dashboardpurger.Sweep")
if err := p.sweep(ctx); err != nil {
span.RecordError(err)
p.settings.Logger().ErrorContext(ctx, "dashboard purge sweep failed", errors.Attr(err))
}
span.End()
}
}
}
func (p *purger) Stop(_ context.Context) error {
close(p.stopC)
return nil
}
// sweep does at most one batch per call. The ticker drives further passes; if
// purge volume is bursty the next tick will pick up the rest.
func (p *purger) sweep(ctx context.Context) error {
ids, err := p.store.ListPurgeable(ctx, p.config.Retention, p.config.BatchSize)
if err != nil {
return err
}
if len(ids) == 0 {
return nil
}
if err := p.store.HardDelete(ctx, ids); err != nil {
return err
}
p.settings.Logger().InfoContext(ctx, "hard-deleted soft-deleted dashboards", slog.Int("count", len(ids)))
return nil
}

View File

@@ -10,7 +10,9 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
@@ -20,20 +22,24 @@ import (
type module struct {
store dashboardtypes.Store
sqlstore sqlstore.SQLStore
settings factory.ScopedProviderSettings
analytics analytics.Analytics
orgGetter organization.Getter
queryParser queryparser.QueryParser
tagModule tag.Module
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser) dashboard.Module {
func NewModule(store dashboardtypes.Store, sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard")
return &module{
store: store,
sqlstore: sqlstore,
settings: scopedProviderSettings,
analytics: analytics,
orgGetter: orgGetter,
queryParser: queryParser,
tagModule: tagModule,
}
}

View File

@@ -2,10 +2,13 @@ package impldashboard
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -63,6 +66,195 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
func (store *store) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.StorableDashboard, *dashboardtypes.StorablePublicDashboard, error) {
type joinedRow struct {
bun.BaseModel `bun:"table:dashboard,alias:d"`
ID valuer.UUID `bun:"id"`
OrgID valuer.UUID `bun:"org_id"`
Data dashboardtypes.StorableDashboardData `bun:"data"`
Locked bool `bun:"locked"`
CreatedAt time.Time `bun:"created_at"`
CreatedBy string `bun:"created_by"`
UpdatedAt time.Time `bun:"updated_at"`
UpdatedBy string `bun:"updated_by"`
PublicID *valuer.UUID `bun:"public_id"`
PublicCreatedAt *time.Time `bun:"public_created_at"`
PublicUpdatedAt *time.Time `bun:"public_updated_at"`
PublicTimeRangeEnabled *bool `bun:"public_time_range_enabled"`
PublicDefaultTimeRange *string `bun:"public_default_time_range"`
}
row := new(joinedRow)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(row).
ColumnExpr("d.id, d.org_id, d.data, d.locked, d.created_at, d.created_by, d.updated_at, d.updated_by").
ColumnExpr("pd.id AS public_id, pd.created_at AS public_created_at, pd.updated_at AS public_updated_at, pd.time_range_enabled AS public_time_range_enabled, pd.default_time_range AS public_default_time_range").
Join("LEFT JOIN public_dashboard AS pd ON pd.dashboard_id = d.id").
Where("d.id = ?", id).
Where("d.org_id = ?", orgID).
Where("d.deleted_at IS NULL").
Scan(ctx)
if err != nil {
return nil, nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
}
storable := &dashboardtypes.StorableDashboard{
Identifiable: types.Identifiable{ID: row.ID},
TimeAuditable: types.TimeAuditable{CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt},
UserAuditable: types.UserAuditable{CreatedBy: row.CreatedBy, UpdatedBy: row.UpdatedBy},
OrgID: row.OrgID,
Data: row.Data,
Locked: row.Locked,
}
if row.PublicID == nil {
return storable, nil, nil
}
public := &dashboardtypes.StorablePublicDashboard{
Identifiable: types.Identifiable{ID: *row.PublicID},
TimeAuditable: types.TimeAuditable{CreatedAt: *row.PublicCreatedAt, UpdatedAt: *row.PublicUpdatedAt},
TimeRangeEnabled: *row.PublicTimeRangeEnabled,
DefaultTimeRange: *row.PublicDefaultTimeRange,
DashboardID: row.ID.StringValue(),
}
return storable, public, nil
}
func (store *store) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.StorableDashboardData) error {
res, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model((*dashboardtypes.StorableDashboard)(nil)).
Set("data = ?", data).
Set("updated_by = ?", updatedBy).
Set("updated_at = ?", time.Now()).
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("deleted_at IS NULL").
Exec(ctx)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
// Defends against the race where a soft-delete lands between the caller's
// pre-update GetV2 and this update.
if rows == 0 {
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
}
return nil
}
func (store *store) ListPurgeable(ctx context.Context, retention time.Duration, limit int) ([]valuer.UUID, error) {
if limit <= 0 {
return nil, nil
}
cutoff := time.Now().Add(-retention)
ids := make([]valuer.UUID, 0, limit)
err := store.
sqlstore.
BunDB().
NewSelect().
Model((*dashboardtypes.StorableDashboard)(nil)).
Column("id").
Where("deleted_at IS NOT NULL").
Where("deleted_at < ?", cutoff).
Limit(limit).
Scan(ctx, &ids)
if err != nil {
return nil, err
}
return ids, nil
}
// HardDelete cascades to tag_relations and public_dashboard inside one
// transaction so a partial failure leaves no orphans.
func (store *store) HardDelete(ctx context.Context, ids []valuer.UUID) error {
if len(ids) == 0 {
return nil
}
return store.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
if _, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*tagtypes.TagRelation)(nil)).
Where("entity_id IN (?)", bun.In(ids)).
Exec(ctx); err != nil {
return err
}
if _, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.StorablePublicDashboard)(nil)).
Where("dashboard_id IN (?)", bun.In(ids)).
Exec(ctx); err != nil {
return err
}
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.StorableDashboard)(nil)).
Where("id IN (?)", bun.In(ids)).
Exec(ctx)
return err
})
}
func (store *store) SoftDeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, deletedBy string) error {
res, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model((*dashboardtypes.StorableDashboard)(nil)).
Set("deleted_at = ?", time.Now()).
Set("deleted_by = ?", deletedBy).
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("deleted_at IS NULL").
Exec(ctx)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
}
return nil
}
func (store *store) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, locked bool, updatedBy string) error {
res, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model((*dashboardtypes.StorableDashboard)(nil)).
Set("locked = ?", locked).
Set("updated_by = ?", updatedBy).
Set("updated_at = ?", time.Now()).
Where("id = ?", id).
Where("deleted_at IS NULL").
Exec(ctx)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
}
return nil
}
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
storable := new(dashboardtypes.StorablePublicDashboard)
err := store.

View File

@@ -0,0 +1,344 @@
package impldashboard
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PostableDashboardV2{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.CreateV2(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}
func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.GetV2(ctx, orgID, dashboardID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, true)
}
func (handler *handler) UnlockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, false)
}
func (handler *handler) lockUnlockV2(rw http.ResponseWriter, r *http.Request, lock bool) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
isAdmin := false
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
}
if err := handler.authz.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
); err == nil {
isAdmin = true
}
if err := handler.module.LockUnlockV2(ctx, orgID, dashboardID, claims.Email, isAdmin, lock); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) UpdateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.UpdateableDashboardV2{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.UpdateV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}
func (handler *handler) PatchV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PatchableDashboardV2{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.PatchV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}
func (handler *handler) CreatePublicV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PostablePublicDashboard{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.CreatePublicV2(ctx, orgID, dashboardID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}
func (handler *handler) UpdatePublicV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.UpdatablePublicDashboard{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.UpdatePublicV2(ctx, orgID, dashboardID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}
func (handler *handler) DeleteV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
if err := handler.module.DeleteV2(ctx, orgID, dashboardID, claims.Email); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

View File

@@ -0,0 +1,189 @@
package impldashboard
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := postable.Validate(); err != nil {
return nil, err
}
// Tag upserts run outside the dashboard transaction by design: a successful
// upsert that loses an outer dashboard insert just leaves resolved tag rows
// around for the next attempt — preferable to coupling the two.
resolvedTags, err := module.tagModule.CreateMany(ctx, orgID, postable.Tags, createdBy)
if err != nil {
return nil, err
}
dashboard := dashboardtypes.NewDashboardV2(orgID, createdBy, postable, resolvedTags)
storableDashboard, err := dashboard.ToStorableDashboard()
if err != nil {
return nil, err
}
tagIDs := make([]valuer.UUID, len(resolvedTags))
for i, t := range resolvedTags {
tagIDs[i] = t.ID
}
err = module.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
if err := module.store.Create(ctx, storableDashboard); err != nil {
return err
}
return module.tagModule.LinkToEntity(ctx, orgID, dashboardtypes.EntityTypeDashboard, dashboard.ID, tagIDs)
})
if err != nil {
return nil, err
}
module.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromStorableDashboards([]*dashboardtypes.StorableDashboard{storableDashboard}))
return dashboard, nil
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storable, public, err := module.store.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
tags, err := module.tagModule.ListForEntity(ctx, id)
if err != nil {
return nil, err
}
return dashboardtypes.NewDashboardV2FromStorable(storable, public, tags)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updateable.Validate(); err != nil {
return nil, err
}
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// safety check before upserting tags. existing.Update also has this checks, but
// because existing.Update needs the resolved tags, that method can only be called
// after the tags have been resolved.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
// Tag upserts run outside the update transaction for the same reason as
// Create: a successful upsert that loses the outer transaction just leaves
// resolved tag rows around for the next attempt.
resolvedTags, err := module.tagModule.CreateMany(ctx, orgID, updateable.Tags, updatedBy)
if err != nil {
return nil, err
}
tagIDs := make([]valuer.UUID, len(resolvedTags))
for i, t := range resolvedTags {
tagIDs[i] = t.ID
}
if err := existing.Update(updateable, updatedBy, resolvedTags); err != nil {
return nil, err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return nil, err
}
err = module.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
if err := module.tagModule.SyncLinksForEntity(ctx, orgID, dashboardtypes.EntityTypeDashboard, id, tagIDs); err != nil {
return err
}
return module.store.UpdateV2(ctx, orgID, id, updatedBy, storable.Data)
})
if err != nil {
return nil, err
}
return existing, nil
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
if err := existing.CanUpdate(); err != nil {
return nil, err
}
updateable, err := patch.Apply(existing)
if err != nil {
return nil, err
}
resolvedTags, err := module.tagModule.CreateMany(ctx, orgID, updateable.Tags, updatedBy)
if err != nil {
return nil, err
}
tagIDs := make([]valuer.UUID, len(resolvedTags))
for i, t := range resolvedTags {
tagIDs[i] = t.ID
}
if err := existing.Update(*updateable, updatedBy, resolvedTags); err != nil {
return nil, err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return nil, err
}
err = module.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
if err := module.tagModule.SyncLinksForEntity(ctx, orgID, dashboardtypes.EntityTypeDashboard, id, tagIDs); err != nil {
return err
}
return module.store.UpdateV2(ctx, orgID, id, updatedBy, storable.Data)
})
if err != nil {
return nil, err
}
return existing, nil
}
// CreatePublicV2 is not supported in the community build.
func (module *module) CreatePublicV2(_ context.Context, _ valuer.UUID, _ valuer.UUID, _ dashboardtypes.PostablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
// UpdatePublicV2 is not supported in the community build.
func (module *module) UpdatePublicV2(_ context.Context, _ valuer.UUID, _ valuer.UUID, _ dashboardtypes.UpdatablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, deletedBy string) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if err := existing.CanDelete(); err != nil {
return err
}
return module.store.SoftDeleteV2(ctx, orgID, id, deletedBy)
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if err := existing.LockUnlock(lock, isAdmin, updatedBy); err != nil {
return err
}
return module.store.LockUnlockV2(ctx, orgID, id, lock, updatedBy)
}

View File

@@ -0,0 +1,53 @@
package impltag
import (
"context"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
store tagtypes.Store
}
func NewModule(store tagtypes.Store) tag.Module {
return &module{store: store}
}
func (m *module) CreateMany(ctx context.Context, orgID valuer.UUID, postable []tagtypes.PostableTag, createdBy string) ([]*tagtypes.Tag, error) {
if len(postable) == 0 {
return []*tagtypes.Tag{}, nil
}
toCreate, matched, err := tagtypes.Resolve(ctx, m.store, orgID, postable, createdBy)
if err != nil {
return nil, err
}
created, err := m.store.Create(ctx, toCreate)
if err != nil {
return nil, err
}
return append(matched, created...), nil
}
func (m *module) LinkToEntity(ctx context.Context, orgID valuer.UUID, entityType tagtypes.EntityType, entityID valuer.UUID, tagIDs []valuer.UUID) error {
if len(tagIDs) == 0 {
return nil
}
return m.store.CreateRelations(ctx, tagtypes.NewTagRelations(orgID, entityType, entityID, tagIDs))
}
func (m *module) SyncLinksForEntity(ctx context.Context, orgID valuer.UUID, entityType tagtypes.EntityType, entityID valuer.UUID, tagIDs []valuer.UUID) error {
if err := m.store.CreateRelations(ctx, tagtypes.NewTagRelations(orgID, entityType, entityID, tagIDs)); err != nil {
return err
}
return m.store.DeleteRelationsExcept(ctx, entityID, tagIDs)
}
func (m *module) ListForEntity(ctx context.Context, entityID valuer.UUID) ([]*tagtypes.Tag, error) {
return m.store.ListByEntity(ctx, entityID)
}

View File

@@ -0,0 +1,95 @@
package impltag
import (
"context"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) tagtypes.Store {
return &store{sqlstore: sqlstore}
}
func (s *store) List(ctx context.Context, orgID valuer.UUID) ([]*tagtypes.Tag, error) {
tags := make([]*tagtypes.Tag, 0)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&tags).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, err
}
return tags, nil
}
func (s *store) ListByEntity(ctx context.Context, entityID valuer.UUID) ([]*tagtypes.Tag, error) {
tags := make([]*tagtypes.Tag, 0)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&tags).
Join("JOIN tag_relations AS tr ON tr.tag_id = tag.id").
Where("tr.entity_id = ?", entityID).
Scan(ctx)
if err != nil {
return nil, err
}
return tags, nil
}
func (s *store) Create(ctx context.Context, tags []*tagtypes.Tag) ([]*tagtypes.Tag, error) {
if len(tags) == 0 {
return tags, nil
}
// DO UPDATE on a self-set is a deliberate no-op write whose only purpose
// is to make RETURNING fire on conflicting rows. Without it, RETURNING is
// silent on the conflict path and we'd have to refetch by internal name to
// learn the existing rows' IDs after a concurrent-insert race.
err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(&tags).
On("CONFLICT (org_id, internal_name) DO UPDATE").
Set("internal_name = EXCLUDED.internal_name").
Returning("*").
Scan(ctx)
if err != nil {
return nil, err
}
return tags, nil
}
func (s *store) CreateRelations(ctx context.Context, relations []*tagtypes.TagRelation) error {
if len(relations) == 0 {
return nil
}
_, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(&relations).
On("CONFLICT (entity_id, tag_id) DO NOTHING").
Exec(ctx)
return err
}
func (s *store) DeleteRelationsExcept(ctx context.Context, entityID valuer.UUID, keepTagIDs []valuer.UUID) error {
q := s.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model((*tagtypes.TagRelation)(nil)).
Where("entity_id = ?", entityID)
if len(keepTagIDs) > 0 {
q = q.Where("tag_id NOT IN (?)", bun.In(keepTagIDs))
}
_, err := q.Exec(ctx)
return err
}

View File

@@ -0,0 +1,140 @@
package impltag
import (
"context"
"path/filepath"
"testing"
"time"
"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/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/uptrace/bun"
)
func newTestStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{
MaxOpenConns: 1,
MaxConnLifetime: 0,
},
Sqlite: sqlstore.SqliteConfig{
Path: dbPath,
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*tagtypes.Tag)(nil)).
IfNotExists().
Exec(context.Background())
require.NoError(t, err)
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_org_id_internal_name ON tag (org_id, internal_name)`)
require.NoError(t, err)
return store
}
func tagsByInternalName(t *testing.T, db *bun.DB) map[string]*tagtypes.Tag {
t.Helper()
all := make([]*tagtypes.Tag, 0)
require.NoError(t, db.NewSelect().Model(&all).Scan(context.Background()))
out := map[string]*tagtypes.Tag{}
for _, tag := range all {
out[tag.InternalName] = tag
}
return out
}
func TestStore_Create_PopulatesIDsOnFreshInsert(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
tagA := tagtypes.NewTag(orgID, "Database", "database", "u@signoz.io")
tagB := tagtypes.NewTag(orgID, "team/BLR", "team::blr", "u@signoz.io")
preIDA := tagA.ID
preIDB := tagB.ID
got, err := s.Create(ctx, []*tagtypes.Tag{tagA, tagB})
require.NoError(t, err)
require.Len(t, got, 2)
// No race → pre-generated IDs stand. The slice is what we passed in,
// confirming Scan didn't reallocate.
assert.Equal(t, preIDA, got[0].ID)
assert.Equal(t, preIDB, got[1].ID)
// And the rows are in the DB.
stored := tagsByInternalName(t, sqlstore.BunDB())
require.Contains(t, stored, "database")
require.Contains(t, stored, "team::blr")
assert.Equal(t, preIDA, stored["database"].ID)
assert.Equal(t, preIDB, stored["team::blr"].ID)
}
func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
// Simulate a concurrent insert: someone else has already inserted "database".
winner := tagtypes.NewTag(orgID, "Database", "database", "concurrent")
_, err := s.Create(ctx, []*tagtypes.Tag{winner})
require.NoError(t, err)
winnerID := winner.ID
// Now our request runs with a different pre-generated ID for the same
// internal name. RETURNING should overwrite our stale ID with winner's ID.
loser := tagtypes.NewTag(orgID, "Database", "database", "u@signoz.io")
loserPreID := loser.ID
require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
got, err := s.Create(ctx, []*tagtypes.Tag{loser})
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
// And the DB still has exactly one row for that internal name — winner's.
stored := tagsByInternalName(t, sqlstore.BunDB())
require.Len(t, stored, 1)
assert.Equal(t, winnerID, stored["database"].ID)
}
func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
pre := tagtypes.NewTag(orgID, "Database", "database", "concurrent")
_, err := s.Create(ctx, []*tagtypes.Tag{pre})
require.NoError(t, err)
preExistingID := pre.ID
conflict := tagtypes.NewTag(orgID, "Database", "database", "u@signoz.io")
fresh := tagtypes.NewTag(orgID, "team/BLR", "team::blr", "u@signoz.io")
freshPreID := fresh.ID
got, err := s.Create(ctx, []*tagtypes.Tag{conflict, fresh})
require.NoError(t, err)
require.Len(t, got, 2)
assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
}

28
pkg/modules/tag/tag.go Normal file
View File

@@ -0,0 +1,28 @@
package tag
import (
"context"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
// CreateMany resolves user-supplied tag names against the existing tags for the
// org — reusing the casing of any existing parent tag so that
// "teams/blr/platform" inherits the "BLR" casing from a pre-existing
// "teams/BLR" tag — and inserts any tags that don't yet exist.
//
// Does not link the resolved tags to any entity — call LinkToEntity for that.
CreateMany(ctx context.Context, orgID valuer.UUID, postable []tagtypes.PostableTag, createdBy string) ([]*tagtypes.Tag, error)
// LinkToEntity inserts (entity, tag) rows in tag_relations. Existing rows
// are left untouched. Uses the caller's transaction context if any so that
// it can be made atomic with the entity row insert.
LinkToEntity(ctx context.Context, orgID valuer.UUID, entityType tagtypes.EntityType, entityID valuer.UUID, tagIDs []valuer.UUID) error
// missing links are inserted, obsolete ones removed.
SyncLinksForEntity(ctx context.Context, orgID valuer.UUID, entityType tagtypes.EntityType, entityID valuer.UUID, tagIDs []valuer.UUID) error
ListForEntity(ctx context.Context, entityID valuer.UUID) ([]*tagtypes.Tag, error)
}

View File

@@ -24,6 +24,7 @@ import (
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard/dashboardpurger"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
@@ -143,6 +144,9 @@ type Config struct {
// Authz config
Authz authz.Config `mapstructure:"authz"`
// DashboardPurger config (hard-deletes soft-deleted dashboards after a TTL).
DashboardPurger dashboardpurger.Config `mapstructure:"dashboardpurger"`
}
func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.ResolverConfig) (Config, error) {
@@ -168,6 +172,7 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
statsreporter.NewConfigFactory(),
gateway.NewConfigFactory(),
tokenizer.NewConfigFactory(),
dashboardpurger.NewConfigFactory(),
metricsexplorer.NewConfigFactory(),
inframonitoring.NewConfigFactory(),
flagger.NewConfigFactory(),

View File

@@ -16,6 +16,7 @@ import (
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -44,7 +45,8 @@ func TestNewHandlers(t *testing.T) {
emailing := emailingtest.New()
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), sqlstore, providerSettings, nil, orgGetter, queryParser, tagModule)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)
@@ -52,7 +54,7 @@ func TestNewHandlers(t *testing.T) {
userRoleStore := impluser.NewUserRoleStore(sqlstore, providerSettings)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, flagger, tagModule)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)

View File

@@ -40,6 +40,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
@@ -80,6 +81,7 @@ type Modules struct {
CloudIntegration cloudintegration.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
Tag tag.Module
}
func NewModules(
@@ -104,6 +106,7 @@ func NewModules(
serviceAccount serviceaccount.Module,
cloudIntegrationModule cloudintegration.Module,
fl flagger.Flagger,
tagModule tag.Module,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
@@ -133,5 +136,6 @@ func NewModules(
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
Tag: tagModule,
}
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sharder"
@@ -45,7 +46,8 @@ func TestNewModules(t *testing.T) {
emailing := emailingtest.New()
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), sqlstore, providerSettings, nil, orgGetter, queryParser, tagModule)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)
@@ -56,7 +58,7 @@ func TestNewModules(t *testing.T) {
serviceAccount := implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), nil, nil, nil, providerSettings, serviceaccount.Config{})
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), flagger, tagModule)
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -195,6 +195,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewServiceAccountAuthzactory(sqlstore),
sqlmigration.NewDropUserDeletedAtFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateAWSAllRegionsFactory(sqlstore),
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
sqlmigration.NewAddDashboardSoftDeleteFactory(sqlstore, sqlschema),
)
}

View File

@@ -29,6 +29,10 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/dashboard/dashboardpurger"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
@@ -101,7 +105,7 @@ func New(
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, []authz.OnBeforeRoleDelete, dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
@@ -325,8 +329,13 @@ func New(
// Initialize query parser (needed for dashboard module)
queryParser := queryparser.New(providerSettings)
// Initialize tag module — shared across modules that link entities to tags
// (currently dashboard; future: alerts, RBAC). Built once here and injected
// where needed.
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
// Initialize dashboard module (needed for authz registry)
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing)
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
// Initialize user getter
userGetter := impluser.NewGetter(userStore, userRoleStore, flagger)
@@ -441,7 +450,7 @@ func New(
}
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, flagger, tagModule)
// Initialize ruler from the variant-specific provider factories
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
@@ -488,6 +497,12 @@ func New(
return nil, err
}
// Initialize dashboard purger — periodic hard-delete of soft-deleted rows.
dashboardPurger, err := dashboardpurger.NewFactory(impldashboard.NewStore(sqlstore)).New(ctx, providerSettings, config.DashboardPurger)
if err != nil {
return nil, err
}
registry, err := factory.NewRegistry(
ctx,
instrumentation.Logger(),
@@ -502,6 +517,7 @@ func New(
factory.NewNamedService(factory.MustNewName("user"), userService, factory.MustNewName("authz")),
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
factory.NewNamedService(factory.MustNewName("dashboardpurger"), dashboardPurger),
)
if err != nil {
return nil, err

View File

@@ -0,0 +1,103 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addTags struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddTagsFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_tags"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTags{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addTags) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTags) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := [][]byte{}
tagTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "tag",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "internal_name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "created_by", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_by", DataType: sqlschema.DataTypeText, Nullable: true},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tagTableSQLs...)
tagUniqueIndexSQL := migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "tag",
ColumnNames: []sqlschema.ColumnName{"org_id", "internal_name"},
})
sqls = append(sqls, tagUniqueIndexSQL...)
tagRelationsTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "tag_relations",
Columns: []*sqlschema.Column{
{Name: "entity_type", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "entity_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "tag_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"entity_id", "tag_id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tagRelationsTableSQLs...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addTags) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,59 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addDashboardSoftDelete struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddDashboardSoftDeleteFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_dashboard_soft_delete"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addDashboardSoftDelete{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addDashboardSoftDelete) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addDashboardSoftDelete) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
dashboardTable := &sqlschema.Table{Name: "dashboard"}
sqls := [][]byte{}
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(
dashboardTable, nil,
&sqlschema.Column{Name: "deleted_at", DataType: sqlschema.DataTypeTimestamp, Nullable: true}, nil,
)...)
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(
dashboardTable, nil,
&sqlschema.Column{Name: "deleted_by", DataType: sqlschema.DataTypeText, Nullable: true}, nil,
)...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addDashboardSoftDelete) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -11,3 +11,8 @@ type UserAuditable struct {
CreatedBy string `bun:"created_by,type:text" json:"createdBy"`
UpdatedBy string `bun:"updated_by,type:text" json:"updatedBy"`
}
type DeleteAuditable struct {
DeletedBy string `bun:"deleted_by,type:text,nullzero" json:"deletedBy,omitempty"`
DeletedAt time.Time `bun:"deleted_at,nullzero" json:"deletedAt,omitzero"`
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -19,6 +20,8 @@ var (
TypeableMetaResourceDashboard = authtypes.MustNewTypeableMetaResource(authtypes.MustNewName("dashboard"))
TypeableMetaResourcePublicDashboard = authtypes.MustNewTypeableMetaResource(authtypes.MustNewName("public-dashboard"))
TypeableMetaResourcesDashboards = authtypes.MustNewTypeableMetaResources(authtypes.MustNewName("dashboards"))
EntityTypeDashboard = tagtypes.MustNewEntityType("dashboard")
)
var (
@@ -34,6 +37,7 @@ type StorableDashboard struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
types.DeleteAuditable
Data StorableDashboardData `bun:"data,type:text,notnull"`
Locked bool `bun:"locked,notnull,default:false"`
OrgID valuer.UUID `bun:"org_id,notnull"`

View File

@@ -1,262 +0,0 @@
package dashboardtypes
import (
"bytes"
"encoding/json"
"fmt"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/go-playground/validator/v10"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
)
// StorableDashboardDataV2 wraps v1.DashboardSpec (Perses) with additional SigNoz-specific fields.
//
// We embed DashboardSpec (not v1.Dashboard) to avoid carrying Perses's Metadata
// (Name, Project, CreatedAt, UpdatedAt, Tags, Version) and Kind field. SigNoz
// manages identity (ID), timestamps (TimeAuditable), and multi-tenancy (OrgID)
// separately on StorableDashboardV2/DashboardV2.
//
// The following v1 request fields map to locations inside v1.DashboardSpec:
// - title → Display.Name (common.Display)
// - description → Display.Description (common.Display)
//
// Fields that have no Perses equivalent will be added in this wrapper (like image, uploadGrafana, etc.)
type StorableDashboardDataV2 = v1.DashboardSpec
// UnmarshalAndValidateDashboardV2JSON unmarshals the JSON into a StorableDashboardDataV2
// (= PostableDashboardV2 = UpdatableDashboardV2) and validates plugin kinds and specs.
func UnmarshalAndValidateDashboardV2JSON(data []byte) (*StorableDashboardDataV2, error) {
var d StorableDashboardDataV2
// Note: DashboardSpec has a custom UnmarshalJSON which prevents
// DisallowUnknownFields from working at the top level. Unknown
// fields in plugin specs are still rejected by validateAndNormalizePluginSpec.
if err := json.Unmarshal(data, &d); err != nil {
return nil, err
}
if err := validateDashboardV2(d); err != nil {
return nil, err
}
return &d, nil
}
// Plugin kind → spec type factory. Each value is a pointer to the zero value of the
// expected spec struct. validatePluginSpec marshals plugin.Spec back to JSON and
// unmarshals into the typed struct to catch field-level errors.
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
QueryKindFormula: func() any { return new(FormulaSpec) },
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
QueryKindClickHouseSQL: func() any { return new(ClickHouseSQLQuerySpec) },
QueryKindTraceOperator: func() any { return new(TraceOperatorSpec) },
}
variablePluginSpecs = map[VariablePluginKind]func() any{
VariableKindDynamic: func() any { return new(DynamicVariableSpec) },
VariableKindQuery: func() any { return new(QueryVariableSpec) },
VariableKindCustom: func() any { return new(CustomVariableSpec) },
VariableKindTextbox: func() any { return new(TextboxVariableSpec) },
}
datasourcePluginSpecs = map[DatasourcePluginKind]func() any{
DatasourceKindSigNoz: func() any { return new(struct{}) },
}
// allowedQueryKinds maps each panel plugin kind to the query plugin
// kinds it supports. Composite sub-query types are mapped to these
// same kind strings via compositeSubQueryTypeToPluginKind.
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
}
// compositeSubQueryTypeToPluginKind maps CompositeQuery sub-query type
// strings to the equivalent top-level query plugin kind for validation.
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
}
)
func validateDashboardV2(d StorableDashboardDataV2) error {
for name, ds := range d.Datasources {
if err := validateDatasourcePlugin(&ds.Plugin, fmt.Sprintf("spec.datasources.%s.plugin", name)); err != nil {
return err
}
}
for i, v := range d.Variables {
if err := validateVariablePlugin(v, fmt.Sprintf("spec.variables[%d]", i)); err != nil {
return err
}
}
for key, panel := range d.Panels {
if panel == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
}
path := fmt.Sprintf("spec.panels.%s", key)
if err := validatePanelPlugin(&panel.Spec.Plugin, path+".spec.plugin"); err != nil {
return err
}
panelKind := PanelPluginKind(panel.Spec.Plugin.Kind)
allowed := allowedQueryKinds[panelKind]
for qi := range panel.Spec.Queries {
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
if err := validateQueryPlugin(&panel.Spec.Queries[qi].Spec.Plugin, queryPath); err != nil {
return err
}
if err := validateQueryAllowedForPanel(panel.Spec.Queries[qi].Spec.Plugin, allowed, panelKind, queryPath); err != nil {
return err
}
}
}
return nil
}
func validateDatasourcePlugin(plugin *common.Plugin, path string) error {
kind := DatasourcePluginKind(plugin.Kind)
factory, ok := datasourcePluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown datasource plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func validateVariablePlugin(v dashboard.Variable, path string) error {
switch spec := v.Spec.(type) {
case *dashboard.ListVariableSpec:
pluginPath := path + ".spec.plugin"
kind := VariablePluginKind(spec.Plugin.Kind)
factory, ok := variablePluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown variable plugin kind %q; allowed values: %s", pluginPath, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(&spec.Plugin, factory, pluginPath)
case *dashboard.TextVariableSpec:
// TextVariables have no plugin, nothing to validate.
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: unsupported variable kind %q", path, v.Kind)
}
}
func validatePanelPlugin(plugin *common.Plugin, path string) error {
kind := PanelPluginKind(plugin.Kind)
factory, ok := panelPluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown panel plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func validateQueryPlugin(plugin *common.Plugin, path string) error {
kind := QueryPluginKind(plugin.Kind)
factory, ok := queryPluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown query plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func formatEnum(values []any) string {
parts := make([]string, len(values))
for i, v := range values {
parts[i] = fmt.Sprintf("`%v`", v)
}
return strings.Join(parts, ", ")
}
// validateAndNormalizePluginSpec validates the plugin spec and writes the typed
// struct (with defaults) back into plugin.Spec so that DB storage and API
// responses contain normalized values.
func validateAndNormalizePluginSpec(plugin *common.Plugin, factory func() any, path string) error {
if plugin.Kind == "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: plugin kind is required", path)
}
if plugin.Spec == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: plugin spec is required", path)
}
// Re-marshal the spec and unmarshal into the typed struct.
specJSON, err := json.Marshal(plugin.Spec)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
target := factory()
decoder := json.NewDecoder(bytes.NewReader(specJSON))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
if err := validator.New().Struct(target); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
// Write the typed struct back so defaults are included.
plugin.Spec = target
return nil
}
// validateQueryAllowedForPanel checks that the query plugin kind is permitted
// for the given panel. For composite queries it recurses into sub-queries.
func validateQueryAllowedForPanel(plugin common.Plugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
queryKind := QueryPluginKind(plugin.Kind)
if !slices.Contains(allowed, queryKind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: query kind %q is not supported by panel kind %q", path, queryKind, panelKind)
}
// For composite queries, validate each sub-query type.
if queryKind == QueryKindComposite && plugin.Spec != nil {
specJSON, err := json.Marshal(plugin.Spec)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
var composite struct {
Queries []struct {
Type qb.QueryType `json:"type"`
} `json:"queries"`
}
if err := json.Unmarshal(specJSON, &composite); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
for si, sub := range composite.Queries {
pluginKind, ok := compositeSubQueryTypeToPluginKind[sub.Type]
if !ok {
continue
}
if !slices.Contains(allowed, pluginKind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s.spec.queries[%d]: sub-query type %q is not supported by panel kind %q",
path, si, sub.Type, panelKind)
}
}
}
return nil
}

View File

@@ -0,0 +1,326 @@
package dashboardtypes
import (
"bytes"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
jsonpatch "gopkg.in/evanphx/json-patch.v4"
)
const (
SchemaVersion = "v6"
MaxTagsPerDashboard = 5
)
type DashboardV2 struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId"`
Locked bool `json:"locked"`
Info DashboardInfo `json:"info"`
PublicConfig *PublicDashboard `json:"publicConfig,omitempty"`
}
// DashboardInfo is the serializable view of a dashboard's contents — what the UI renders as "the dashboard JSON".
type DashboardInfo struct {
StoredDashboardInfo
Tags []*tagtypes.Tag `json:"tags,omitempty"`
}
// StoredDashboardInfo is exactly what serializes into the dashboard.data column.
type StoredDashboardInfo struct {
Metadata DashboardMetadata `json:"metadata"`
Data DashboardData `json:"data"`
}
type DashboardMetadata struct {
SchemaVersion string `json:"schemaVersion"`
Image string `json:"image,omitempty"`
UploadedGrafana bool `json:"uploadedGrafana"`
}
type PostableDashboardV2 struct {
StoredDashboardInfo
Tags []tagtypes.PostableTag `json:"tags,omitempty"`
}
type UpdateableDashboardV2 = PostableDashboardV2
// PatchableDashboardV2 is an RFC 6902 JSON Patch document applied against a
// PostableDashboardV2-shaped view of an existing dashboard. Patch ops can
// target any field — including individual entries inside `data.panels`,
// `data.panels.<id>.spec.queries`, or `tags` — without re-sending the rest of
// the dashboard.
type PatchableDashboardV2 struct {
patch jsonpatch.Patch
}
// JSONPatchDocument is the OpenAPI-facing schema for an RFC 6902 patch body.
// PatchableDashboardV2 has only an internal `jsonpatch.Patch` field, so the
// reflector would emit an empty schema; the handler def points at this type
// instead so consumers see the array-of-ops shape.
type JSONPatchDocument []JSONPatchOperation
// JSONPatchOperation is one RFC 6902 op. Not every field is valid on every
// op kind (e.g. `value` is required for add/replace/test, ignored for remove;
// `from` is required for move/copy) — the JSON Patch RFC governs that.
type JSONPatchOperation struct {
Op string `json:"op" required:"true"`
Path string `json:"path" required:"true" description:"JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /data/display/name, /data/panels/<id>, /data/panels/<id>/spec/queries/0, /tags/-."`
Value any `json:"value,omitempty" description:"Value to add/replace/test against. The expected type depends on the path — a string for /data/display/name, a Panel for /data/panels/<id>, a PostableTag for /tags/-, etc. Required for add/replace/test; ignored for remove/move/copy."`
From string `json:"from,omitempty" description:"Source JSON Pointer for move/copy ops; ignored for other ops."`
}
// PrepareJSONSchema constrains the `op` field to the six RFC 6902 verbs.
func (JSONPatchOperation) PrepareJSONSchema(s *jsonschema.Schema) error {
op, ok := s.Properties["op"]
if !ok || op.TypeObject == nil {
return errors.NewInternalf(errors.CodeInternal, "JSONPatchOperation schema missing `op` property")
}
op.TypeObject.WithEnum("add", "remove", "replace", "move", "copy", "test")
s.Properties["op"] = op
return nil
}
func (p *PatchableDashboardV2) UnmarshalJSON(data []byte) error {
patch, err := jsonpatch.DecodePatch(data)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
p.patch = patch
return nil
}
// patchableDashboardV2View is the JSON shape a patch is applied against.
// It mirrors PostableDashboardV2 except `tags` is always emitted (even when
// empty) — RFC 6902 `add /tags/-` requires the array to exist in the target
// document, and PostableDashboardV2's own `omitempty` on tags would drop it.
type patchableDashboardV2View struct {
StoredDashboardInfo
Tags []tagtypes.PostableTag `json:"tags"`
}
// Apply runs the patch against the existing dashboard. The dashboard is
// projected into the postable JSON shape, the patch is applied, and the
// result is decoded back into an UpdateableDashboardV2 — which re-runs
// the full v2 validation chain.
func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdateableDashboardV2, error) {
postableTags := make([]tagtypes.PostableTag, len(existing.Info.Tags))
for i, t := range existing.Info.Tags {
postableTags[i] = tagtypes.PostableTag{Name: t.Name}
}
base := patchableDashboardV2View{
StoredDashboardInfo: existing.Info.StoredDashboardInfo,
Tags: postableTags,
}
raw, err := json.Marshal(base)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal existing dashboard for patch")
}
patched, err := p.patch.Apply(raw)
if err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
out := &UpdateableDashboardV2{}
if err := json.Unmarshal(patched, out); err != nil {
return nil, err
}
return out, nil
}
func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias PostableDashboardV2
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*p = PostableDashboardV2(tmp)
return p.Validate()
}
func (p *PostableDashboardV2) Validate() error {
if p.Metadata.SchemaVersion != SchemaVersion {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "metadata.schemaVersion must be %q, got %q", SchemaVersion, p.Metadata.SchemaVersion)
}
if p.Data.Display == nil || p.Data.Display.Name == "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "data.display.name is required")
}
if len(p.Tags) > MaxTagsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
}
return p.Data.Validate()
}
type GettableDashboardV2 struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId"`
Locked bool `json:"locked"`
Info GettableDashboardInfo `json:"info"`
PublicConfig *GettablePublicDasbhboard `json:"publicConfig,omitempty"`
}
type GettableDashboardInfo struct {
StoredDashboardInfo
Tags []*tagtypes.GettableTag `json:"tags,omitempty"`
}
func NewGettableDashboardV2FromDashboardV2(dashboard *DashboardV2) *GettableDashboardV2 {
gettable := &GettableDashboardV2{
Identifiable: dashboard.Identifiable,
TimeAuditable: dashboard.TimeAuditable,
UserAuditable: dashboard.UserAuditable,
OrgID: dashboard.OrgID,
Locked: dashboard.Locked,
Info: GettableDashboardInfo{
StoredDashboardInfo: dashboard.Info.StoredDashboardInfo,
Tags: tagtypes.NewGettableTagsFromTags(dashboard.Info.Tags),
},
}
if dashboard.PublicConfig != nil {
gettable.PublicConfig = NewGettablePublicDashboard(dashboard.PublicConfig)
}
return gettable
}
func NewDashboardV2(orgID valuer.UUID, createdBy string, postable PostableDashboardV2, resolvedTags []*tagtypes.Tag) *DashboardV2 {
now := time.Now()
return &DashboardV2{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
UserAuditable: types.UserAuditable{CreatedBy: createdBy, UpdatedBy: createdBy},
OrgID: orgID,
Locked: false,
Info: DashboardInfo{
StoredDashboardInfo: StoredDashboardInfo{
Metadata: postable.Metadata,
Data: postable.Data,
},
Tags: resolvedTags,
},
}
}
// rejects rows that don't carry a v2-shape blob — those are pre-migration v1 dashboards that the v2 API can't render.
func NewDashboardV2FromStorable(storable *StorableDashboard, public *StorablePublicDashboard, tags []*tagtypes.Tag) (*DashboardV2, error) {
metadata, _ := storable.Data["metadata"].(map[string]any)
if metadata == nil || metadata["schemaVersion"] != SchemaVersion {
return nil, errors.Newf(errors.TypeUnsupported, ErrCodeDashboardInvalidData, "dashboard %s is not in %s schema", storable.ID, SchemaVersion)
}
raw, err := json.Marshal(storable.Data)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal stored v2 dashboard data")
}
var stored StoredDashboardInfo
if err := json.Unmarshal(raw, &stored); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal stored v2 dashboard data")
}
var publicConfig *PublicDashboard
if public != nil {
publicConfig = NewPublicDashboardFromStorablePublicDashboard(public)
}
return &DashboardV2{
Identifiable: storable.Identifiable,
TimeAuditable: storable.TimeAuditable,
UserAuditable: storable.UserAuditable,
OrgID: storable.OrgID,
Locked: storable.Locked,
Info: DashboardInfo{
StoredDashboardInfo: stored,
Tags: tags,
},
PublicConfig: publicConfig,
}, nil
}
func (d *DashboardV2) CanLockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if d.CreatedBy != updatedBy && !isAdmin {
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
}
if d.Locked == lock {
if lock {
return errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "dashboard is already locked")
}
return errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "dashboard is already unlocked")
}
return nil
}
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if err := d.CanLockUnlock(lock, isAdmin, updatedBy); err != nil {
return err
}
d.Locked = lock
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
func (d *DashboardV2) CanUpdate() error {
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
}
return nil
}
func (d *DashboardV2) CanDelete() error {
return d.CanUpdate()
}
func (d *DashboardV2) Update(updateable UpdateableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if err := d.CanUpdate(); err != nil {
return err
}
d.Info.Metadata = updateable.Metadata
d.Info.Data = updateable.Data
d.Info.Tags = resolvedTags
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
// ToStorableDashboard packages a Dashboard into the bun row that goes into
// the dashboard table. Tags are intentionally omitted — they live in
// tag_relations and are inserted separately by the caller.
func (d *DashboardV2) ToStorableDashboard() (*StorableDashboard, error) {
data, err := d.Info.toStorableDashboardData()
if err != nil {
return nil, err
}
return &StorableDashboard{
Identifiable: types.Identifiable{ID: d.ID},
TimeAuditable: d.TimeAuditable,
UserAuditable: d.UserAuditable,
OrgID: d.OrgID,
Locked: d.Locked,
Data: data,
}, nil
}
func (s StoredDashboardInfo) toStorableDashboardData() (StorableDashboardData, error) {
raw, err := json.Marshal(s)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v2 dashboard data")
}
out := StorableDashboardData{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal v2 dashboard data")
}
return out, nil
}

View File

@@ -0,0 +1,107 @@
package dashboardtypes
import (
"bytes"
"encoding/json"
"fmt"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
)
// DashboardData is the SigNoz dashboard v2 spec shape. It mirrors
// v1.DashboardSpec (Perses) field-for-field, except every common.Plugin
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
// per-site discriminated oneOf.
type DashboardData struct {
Display *common.Display `json:"display,omitempty"`
Datasources map[string]*DatasourceSpec `json:"datasources,omitempty"`
Variables []Variable `json:"variables,omitempty"`
Panels map[string]*Panel `json:"panels"`
Layouts []Layout `json:"layouts"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval,omitempty"`
Links []v1.Link `json:"links,omitempty"`
}
// ══════════════════════════════════════════════
// Unmarshal + validate entry point
// ══════════════════════════════════════════════
func (d *DashboardData) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias DashboardData
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid dashboard spec")
}
*d = DashboardData(tmp)
return d.Validate()
}
// ══════════════════════════════════════════════
// Cross-field validation
// ══════════════════════════════════════════════
func (d *DashboardData) Validate() error {
for key, panel := range d.Panels {
if panel == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
}
path := fmt.Sprintf("spec.panels.%s", key)
panelKind := panel.Spec.Plugin.Kind
if len(panel.Spec.Queries) == 0 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have at least one query", path)
}
allowed := allowedQueryKinds[panelKind]
for qi, q := range panel.Spec.Queries {
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
if err := validateQueryAllowedForPanel(q.Spec.Plugin, allowed, panelKind, queryPath); err != nil {
return err
}
}
}
return nil
}
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
if !slices.Contains(allowed, plugin.Kind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: query kind %q is not supported by panel kind %q", path, plugin.Kind, panelKind)
}
if plugin.Kind != QueryKindComposite {
return nil
}
composite, ok := plugin.Spec.(*CompositeQuerySpec)
if !ok || composite == nil {
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "%s: composite query plugin has unexpected spec type %T", path, plugin.Spec)
}
for si, sub := range composite.Queries {
subKind, ok := compositeSubQueryTypeToPluginKind[sub.Type]
if !ok {
continue
}
if !slices.Contains(allowed, subKind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s.spec.queries[%d]: sub-query type %q is not supported by panel kind %q",
path, si, sub.Type, panelKind)
}
}
return nil
}
var (
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
}
)

View File

@@ -0,0 +1,521 @@
package dashboardtypes
import (
"encoding/json"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// basePostableJSON is the postable shape of a small but realistic v2
// dashboard used as the base document for patch tests. Each panel carries
// one builder query in the same shape production dashboards use
// (aggregations, filter, groupBy populated), and the dashboard has one
// variable — the variable is not patched in any test here, that's
// covered in a separate variable-focused suite.
const basePostableJSON = `{
"metadata": {"schemaVersion": "v6"},
"data": {
"display": {"name": "Service overview"},
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "service",
"allowAllValue": true,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}],
"filter": {"expression": "service.name IN $service"},
"groupBy": [{"name": "service.name", "fieldDataType": "string", "fieldContext": "tag"}]
}}}
}
]
}
},
"p2": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "X",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_latency_count",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"display": {"title": "Row 1"},
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
]
}
}
],
"duration": "1h"
},
"tags": [{"name": "team/alpha"}, {"name": "env/prod"}]
}`
func TestPatchableDashboardV2_Apply(t *testing.T) {
// Apply doesn't mutate the input *DashboardV2 — it marshals it to
// JSON, applies the patch, and unmarshals the result into a fresh
// struct. Sharing one base across subtests is safe.
var p PostableDashboardV2
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
base := &DashboardV2{
Info: DashboardInfo{
StoredDashboardInfo: p.StoredDashboardInfo,
Tags: []*tagtypes.Tag{
{Name: "team/alpha", InternalName: "team::alpha"},
{Name: "env/prod", InternalName: "env::prod"},
},
},
}
decode := func(t *testing.T, body string) PatchableDashboardV2 {
t.Helper()
var patch PatchableDashboardV2
require.NoError(t, json.Unmarshal([]byte(body), &patch))
return patch
}
// jsonOf marshals the patched dashboard back to JSON so subtests can
// assert on field values without reaching into the typed plugin specs.
jsonOf := func(t *testing.T, out *UpdateableDashboardV2) string {
t.Helper()
raw, err := json.Marshal(out)
require.NoError(t, err)
return string(raw)
}
// ─────────────────────────────────────────────────────────────────
// Successful patches
// ─────────────────────────────────────────────────────────────────
t.Run("no-op preserves all fields", func(t *testing.T) {
out, err := decode(t, `[]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, base.Info.Metadata, out.Metadata)
assert.Equal(t, base.Info.Data.Display.Name, out.Data.Display.Name)
require.Equal(t, len(base.Info.Data.Panels), len(out.Data.Panels))
for k, panel := range base.Info.Data.Panels {
require.Contains(t, out.Data.Panels, k)
assert.Equal(t, panel.Spec.Plugin.Kind, out.Data.Panels[k].Spec.Plugin.Kind)
}
assert.Len(t, out.Tags, len(base.Info.Tags))
assert.Len(t, out.Data.Variables, len(base.Info.Data.Variables))
assert.Len(t, out.Data.Layouts, len(base.Info.Data.Layouts))
})
t.Run("add metadata image", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/metadata/image", "value": "https://example.com/img.png"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "https://example.com/img.png", out.Metadata.Image)
assert.Equal(t, SchemaVersion, out.Metadata.SchemaVersion, "schemaVersion preserved")
})
t.Run("replace display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/data/display/name", "value": "Renamed"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Renamed", out.Data.Display.Name)
})
// Per RFC 6902 § 4.1, `add` on an existing object member replaces the
// existing value rather than erroring — same effect as `replace`.
t.Run("add overwrites existing display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/data/display/name", "value": "Overwritten"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Overwritten", out.Data.Display.Name)
})
t.Run("add data refreshInterval", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/data/refreshInterval", "value": "30s"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "30s", string(out.Data.RefreshInterval))
})
t.Run("add panel leaves others untouched", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/data/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Data.Panels, 3)
assert.Contains(t, out.Data.Panels, "p1")
assert.Contains(t, out.Data.Panels, "p2")
assert.Contains(t, out.Data.Panels, "p3")
})
t.Run("replace single panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/data/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, PanelPluginKind("signoz/BarChartPanel"), out.Data.Panels["p2"].Spec.Plugin.Kind)
assert.Equal(t, PanelPluginKind("signoz/TimeSeriesPanel"), out.Data.Panels["p1"].Spec.Plugin.Kind, "p1 untouched")
})
// Removing a panel realistically also drops its layout item — exercise
// the multi-op shape the UI sends.
t.Run("remove panel and its layout item", func(t *testing.T) {
out, err := decode(t, `[
{"op": "remove", "path": "/data/panels/p2"},
{"op": "remove", "path": "/data/layouts/0/spec/items/1"}
]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Data.Panels, 1)
assert.Contains(t, out.Data.Panels, "p1")
assert.NotContains(t, out.Data.Panels, "p2")
raw := jsonOf(t, out)
assert.NotContains(t, raw, `"$ref":"#/spec/panels/p2"`)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p1"`)
})
// The headline use case: edit a single field of a single query inside
// one panel without re-sending any other part of the dashboard.
t.Run("rename single query inside panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/data/panels/p1/spec/queries/0/spec/plugin/spec/name",
"value": "renamed"
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Data.Panels["p1"].Spec.Queries, 1)
assert.Contains(t, jsonOf(t, out), `"name":"renamed"`)
})
// Replace a query at a specific index — swaps query "A" out for "B"
// without re-sending the rest of the panel.
t.Run("replace query at index", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/data/panels/p1/spec/queries/0",
"value": {
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_db_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Data.Panels["p1"].Spec.Queries, 1)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"name":"B"`)
assert.NotContains(t, raw, `"name":"A"`)
})
// ─────────────────────────────────────────────────────────────────
// Layout edits
// ─────────────────────────────────────────────────────────────────
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/data/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
// The first item used to live at x=0, now lives at x=6.
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
})
t.Run("resize panel by editing layout width", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/data/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"width":12`)
})
t.Run("rename layout row title", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/data/layouts/0/spec/display/title", "value": "Latency"}]`).Apply(base)
require.NoError(t, err)
assert.Contains(t, jsonOf(t, out), `"title":"Latency"`)
})
t.Run("append layout item", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/data/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
}]`).Apply(base)
require.NoError(t, err)
// Item count went 2 → 3.
raw := jsonOf(t, out)
assert.Equal(t, 3, strings.Count(raw, `"$ref":"#/spec/panels/`))
})
// Composing add-panel + add-layout-item is the realistic shape of the
// "add a new chart to my dashboard" UI flow — exercise it end-to-end.
t.Run("add panel and corresponding layout item", func(t *testing.T) {
out, err := decode(t, `[
{
"op": "add",
"path": "/data/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}]
}}}
}]
}
}
},
{
"op": "add",
"path": "/data/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}
}
]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Data.Panels, 3)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p3"`)
})
t.Run("append tag", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"name": "env/staging"}}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 3)
assert.Equal(t, "env/staging", out.Tags[2].Name)
})
t.Run("append tag when none exist", func(t *testing.T) {
noTagsBase := &DashboardV2{
Info: DashboardInfo{
StoredDashboardInfo: base.Info.StoredDashboardInfo,
Tags: nil,
},
}
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"name": "team/new"}}]`).Apply(noTagsBase)
require.NoError(t, err)
require.Len(t, out.Tags, 1)
assert.Equal(t, "team/new", out.Tags[0].Name)
})
t.Run("replace tag name", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/tags/0/name", "value": "team/beta"}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 2)
assert.Equal(t, "team/beta", out.Tags[0].Name)
assert.Equal(t, "env/prod", out.Tags[1].Name, "tag at index 1 untouched")
for _, tag := range out.Tags {
assert.NotEqual(t, "team/alpha", tag.Name, "old tag name must be gone")
}
})
t.Run("multiple ops applied in order", func(t *testing.T) {
out, err := decode(t, `[
{"op": "replace", "path": "/data/display/name", "value": "Multi-step"},
{"op": "remove", "path": "/data/panels/p2"},
{"op": "add", "path": "/tags/-", "value": {"name": "env/staging"}}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Multi-step", out.Data.Display.Name)
assert.Len(t, out.Data.Panels, 1)
assert.Len(t, out.Tags, 3)
})
// `test` is an RFC 6902 precondition op: aborts the patch if the value
// at the path doesn't equal the supplied value. Used for optimistic
// concurrency. Here it matches, so the subsequent ops apply.
t.Run("test op passes", func(t *testing.T) {
out, err := decode(t, `[
{"op": "test", "path": "/data/display/name", "value": "Service overview"},
{"op": "replace", "path": "/data/display/name", "value": "Confirmed"}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Confirmed", out.Data.Display.Name)
})
// ─────────────────────────────────────────────────────────────────
// Failure cases
// ─────────────────────────────────────────────────────────────────
t.Run("decode rejects non-array body", func(t *testing.T) {
var patch PatchableDashboardV2
err := json.Unmarshal([]byte(`{"op": "replace"}`), &patch)
require.Error(t, err)
})
t.Run("decode rejects malformed JSON", func(t *testing.T) {
var patch PatchableDashboardV2
// Outer json.Unmarshal rejects non-JSON before PatchableDashboardV2's
// UnmarshalJSON runs, so the error is a stdlib SyntaxError rather
// than the InvalidInput-classified wrap.
err := json.Unmarshal([]byte(`not json`), &patch)
require.Error(t, err)
})
// `test` precondition fails — the whole patch is rejected, including
// the subsequent replace.
t.Run("test op failure rejected", func(t *testing.T) {
_, err := decode(t, `[
{"op": "test", "path": "/data/display/name", "value": "Wrong"},
{"op": "replace", "path": "/data/display/name", "value": "Should not apply"}
]`).Apply(base)
require.Error(t, err)
})
t.Run("remove at missing path rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "remove", "path": "/data/panels/does-not-exist"}]`).Apply(base)
require.Error(t, err)
})
t.Run("remove schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "remove", "path": "/metadata/schemaVersion"}]`).Apply(base)
require.Error(t, err)
})
t.Run("wrong schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "replace", "path": "/metadata/schemaVersion", "value": "v5"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), SchemaVersion)
})
t.Run("empty display name rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "replace", "path": "/data/display/name", "value": ""}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "data.display.name is required")
})
t.Run("unknown top-level field rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "add", "path": "/bogus", "value": 42}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "bogus")
})
t.Run("invalid panel kind rejected", func(t *testing.T) {
_, err := decode(t, `[{
"op": "replace",
"path": "/data/panels/p1",
"value": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
}
}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "NotAPanel")
})
t.Run("query kind incompatible with panel rejected", func(t *testing.T) {
// PromQLQuery is not allowed on ListPanel — verify the cross-check
// in Validate still runs after a patch.
_, err := decode(t, `[{
"op": "replace",
"path": "/data/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
}]`).Apply(base)
require.Error(t, err)
})
t.Run("removing only query rejected", func(t *testing.T) {
// p2 has exactly one query in the base; removing it would strand
// the panel queryless, which Validate now rejects.
_, err := decode(t, `[{"op": "remove", "path": "/data/panels/p2/spec/queries/0"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "at least one query")
})
t.Run("too many tags rejected", func(t *testing.T) {
_, err := decode(t, `[
{"op": "add", "path": "/tags/-", "value": {"name": "t1"}},
{"op": "add", "path": "/tags/-", "value": {"name": "t2"}},
{"op": "add", "path": "/tags/-", "value": {"name": "t3"}},
{"op": "add", "path": "/tags/-", "value": {"name": "t4"}}
]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "at most")
})
}

View File

@@ -7,33 +7,75 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func unmarshalDashboard(data []byte) (*DashboardData, error) {
var d DashboardData
if err := json.Unmarshal(data, &d); err != nil {
return nil, err
}
return &d, nil
}
func TestValidateBigExample(t *testing.T) {
data, err := os.ReadFile("testdata/perses.json")
require.NoError(t, err, "reading example file")
_, err = UnmarshalAndValidateDashboardV2JSON(data)
_, err = unmarshalDashboard(data)
require.NoError(t, err, "expected valid dashboard")
}
func TestValidateDashboardWithSections(t *testing.T) {
data, err := os.ReadFile("testdata/perses_with_sections.json")
require.NoError(t, err, "reading example file")
_, err = UnmarshalAndValidateDashboardV2JSON(data)
_, err = unmarshalDashboard(data)
require.NoError(t, err, "expected valid dashboard")
}
func TestInvalidateNotAJSON(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte("not json"))
_, err := unmarshalDashboard([]byte("not json"))
require.Error(t, err, "expected error for invalid JSON")
}
// TestUnmarshalErrorPreservesNestedMessage guards the wrap on dec.Decode in
// DashboardData.UnmarshalJSON. The wrap stamps a consistent type/code on
// decode failures, but must not smother the rich messages produced by nested
// UnmarshalJSON methods (panel/query/variable/datasource plugin envelopes).
func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "NonExistentPanel", "spec": {}}
}
}
},
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown panel plugin kind",
"outer wrap should not smother the inner UnmarshalJSON message")
require.Contains(t, err.Error(), `"NonExistentPanel"`,
"the offending value should still appear in the error")
require.Contains(t, err.Error(), "allowed values:",
"the allowed-values hint should still appear in the error")
assert.True(t, errors.Ast(err, errors.TypeInvalidInput),
"outer wrap should classify the error as TypeInvalidInput")
assert.True(t, errors.Asc(err, ErrCodeDashboardInvalidInput),
"outer wrap should stamp ErrCodeDashboardInvalidInput")
}
func TestValidateEmptySpec(t *testing.T) {
// no variables no panels
data := []byte(`{}`)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
_, err := unmarshalDashboard(data)
require.NoError(t, err, "expected valid")
}
@@ -59,17 +101,13 @@ func TestValidateOnlyVariables(t *testing.T) {
"kind": "TextVariable",
"spec": {
"name": "mytext",
"value": "default",
"plugin": {
"kind": "signoz/TextboxVariable",
"spec": {}
}
"value": "default"
}
}
],
"layouts": []
}`)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
_, err := unmarshalDashboard(data)
require.NoError(t, err, "expected valid")
}
@@ -148,7 +186,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
@@ -169,7 +207,7 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
},
"layouts": []
}`)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for invalid panel plugin kind")
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
}
@@ -245,7 +283,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error for unknown field")
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
@@ -323,7 +361,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected validation error")
if tt.wantContain != "" {
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
@@ -531,13 +569,46 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
func TestInvalidatePanelWithoutQueries(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}}}
}
},
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected panel-without-queries to be rejected")
require.Contains(t, err.Error(), "at least one query")
}
func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": []
}
}
},
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected panel with explicit empty queries array to be rejected")
require.Contains(t, err.Error(), "at least one query")
}
func TestValidateRequiredFields(t *testing.T) {
wrapVariable := func(pluginKind, pluginSpec string) string {
return `{
@@ -626,7 +697,7 @@ func TestValidateRequiredFields(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
@@ -642,13 +713,14 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
}
},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"layouts": []
}`)
d, err := UnmarshalAndValidateDashboardV2JSON(data)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
// After validation+normalization, the plugin spec should be a typed struct.
@@ -689,13 +761,14 @@ func TestNumberPanelDefaults(t *testing.T) {
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
}
},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"layouts": []
}`)
d, err := UnmarshalAndValidateDashboardV2JSON(data)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &NumberPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
@@ -716,6 +789,30 @@ func TestNumberPanelDefaults(t *testing.T) {
"expected stored/response JSON to contain operator:>, got: %s", outputStr)
}
// TestPersesFixtureStorageRoundTrip exercises the typed → map[string]any →
// typed cycle that the create/get path performs against the kitchen-sink
// fixture. Catches plugin specs whose UnmarshalJSON expects a different shape
// than the default MarshalJSON emits.
func TestPersesFixtureStorageRoundTrip(t *testing.T) {
raw, err := os.ReadFile("testdata/perses.json")
require.NoError(t, err)
var data DashboardData
require.NoError(t, json.Unmarshal(raw, &data), "initial unmarshal")
marshaled, err := json.Marshal(data)
require.NoError(t, err, "marshal typed → JSON")
var asMap map[string]any
require.NoError(t, json.Unmarshal(marshaled, &asMap), "JSON → map (storage shape)")
remarshaled, err := json.Marshal(asMap)
require.NoError(t, err, "map → JSON (read-back shape)")
var roundtripped DashboardData
require.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
}
// TestStorageRoundTrip simulates the future DB store/load cycle:
// marshal the normalized dashboard to JSON (what would be written to DB),
// then unmarshal it back (what would be read from DB), and verify defaults survive.
@@ -728,7 +825,8 @@ func TestStorageRoundTrip(t *testing.T) {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
}
},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
},
"p2": {
@@ -737,7 +835,8 @@ func TestStorageRoundTrip(t *testing.T) {
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
}
},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
@@ -745,7 +844,7 @@ func TestStorageRoundTrip(t *testing.T) {
}`)
// Step 1: Unmarshal + validate + normalize (what the API handler does).
d, err := UnmarshalAndValidateDashboardV2JSON(input)
d, err := unmarshalDashboard(input)
require.NoError(t, err, "unmarshal and validate failed")
// Step 1.5: Verify struct fields have correct defaults (extra validation before storing).
@@ -765,7 +864,7 @@ func TestStorageRoundTrip(t *testing.T) {
require.NoError(t, err, "marshal for storage failed")
// Step 3: Unmarshal from JSON (simulates reading from DB).
loaded, err := UnmarshalAndValidateDashboardV2JSON(stored)
loaded, err := unmarshalDashboard(stored)
require.NoError(t, err, "unmarshal from storage failed")
// Step 3.5: Verify struct fields have correct defaults after loading (before returning in API).
@@ -878,7 +977,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON(tc.data)
_, err := unmarshalDashboard(tc.data)
if tc.wantErr {
require.Error(t, err)
} else {

View File

@@ -0,0 +1,170 @@
package dashboardtypes
// TestDashboardDataMatchesPerses asserts that DashboardData
// and every nested SigNoz-owned type cover the JSON field set of their Perses
// counterpart.
import (
"reflect"
"sort"
"strings"
"testing"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
"github.com/stretchr/testify/assert"
)
func TestDashboardDataMatchesPerses(t *testing.T) {
cases := []struct {
name string
ours reflect.Type
perses reflect.Type
}{
{"DashboardSpec", typeOf[DashboardData](), typeOf[v1.DashboardSpec]()},
{"Panel", typeOf[Panel](), typeOf[v1.Panel]()},
{"PanelSpec", typeOf[PanelSpec](), typeOf[v1.PanelSpec]()},
{"Query", typeOf[Query](), typeOf[v1.Query]()},
{"QuerySpec", typeOf[QuerySpec](), typeOf[v1.QuerySpec]()},
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[v1.DatasourceSpec]()},
{"Variable", typeOf[Variable](), typeOf[dashboard.Variable]()},
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
{"Layout", typeOf[Layout](), typeOf[dashboard.Layout]()},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
missing, extra := drift(c.ours, c.perses)
assert.Empty(t, missing,
"DashboardData (%s) is missing json fields present on Perses %s — upstream likely added or renamed a field",
c.ours.Name(), c.perses.Name())
assert.Empty(t, extra,
"DashboardData (%s) has json fields absent on Perses %s — upstream likely removed a field or we added one without the counterpart",
c.ours.Name(), c.perses.Name())
})
}
}
func TestDriftDetectionMechanics(t *testing.T) {
t.Run("upstream added a field", func(t *testing.T) {
type ours struct {
Name string `json:"name"`
}
type perses struct {
Name string `json:"name"`
Description string `json:"description"`
}
missing, extra := drift(typeOf[ours](), typeOf[perses]())
assert.Equal(t, []string{"description"}, missing, "missing fires: upstream has a field we don't")
assert.Empty(t, extra)
})
t.Run("upstream removed a field", func(t *testing.T) {
type ours struct {
Name string `json:"name"`
Description string `json:"description"`
}
type perses struct {
Name string `json:"name"`
}
missing, extra := drift(typeOf[ours](), typeOf[perses]())
assert.Empty(t, missing)
assert.Equal(t, []string{"description"}, extra, "extra fires: we kept a field upstream removed")
})
t.Run("upstream renamed a field", func(t *testing.T) {
type ours struct {
Name string `json:"name"`
}
type perses struct {
Name string `json:"title"`
}
missing, extra := drift(typeOf[ours](), typeOf[perses]())
assert.Equal(t, []string{"title"}, missing, "missing fires for the new name")
assert.Equal(t, []string{"name"}, extra, "extra fires for the old name — both fire on a rename")
})
t.Run("we added a field upstream does not have", func(t *testing.T) {
type ours struct {
Name string `json:"name"`
Internal string `json:"internal"`
}
type perses struct {
Name string `json:"name"`
}
missing, extra := drift(typeOf[ours](), typeOf[perses]())
assert.Empty(t, missing)
assert.Equal(t, []string{"internal"}, extra, "extra fires: we added a field with no upstream counterpart")
})
t.Run("embedded struct flattens — drift inside the embed is caught", func(t *testing.T) {
type embedded struct {
Display string `json:"display"`
NewBit string `json:"newBit"` // upstream added this inside the embed
}
type ours struct {
Display string `json:"display"`
Name string `json:"name"`
}
type perses struct {
embedded `json:",inline"`
Name string `json:"name"`
}
missing, extra := drift(typeOf[ours](), typeOf[perses]())
assert.Equal(t, []string{"newBit"}, missing, "field added inside an inlined embed surfaces at the parent level")
assert.Empty(t, extra)
})
}
func drift(ours, perses reflect.Type) (missing, extra []string) {
o, p := jsonFields(ours), jsonFields(perses)
return sortedDiff(p, o), sortedDiff(o, p)
}
// jsonFields returns the set of json tag names for a struct, flattening
// anonymous embedded fields (matching encoding/json behavior).
func jsonFields(t reflect.Type) map[string]struct{} {
out := map[string]struct{}{}
if t.Kind() != reflect.Struct {
return out
}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
// Skip unexported fields (e.g., dashboard.ListVariableSpec has an
// unexported `variableSpec` interface tag).
if !f.IsExported() && !f.Anonymous {
continue
}
tag := f.Tag.Get("json")
name := strings.Split(tag, ",")[0]
// Anonymous embed with empty json name (no tag, or `json:",inline"` /
// `json:",omitempty"`-style options-only tag) is flattened by encoding/json.
if f.Anonymous && name == "" {
for k := range jsonFields(f.Type) {
out[k] = struct{}{}
}
continue
}
if tag == "-" || name == "" {
continue
}
out[name] = struct{}{}
}
return out
}
// sortedDiff returns keys in a but not in b, sorted.
func sortedDiff(a, b map[string]struct{}) []string {
var diff []string
for k := range a {
if _, ok := b[k]; !ok {
diff = append(diff, k)
}
}
sort.Strings(diff)
return diff
}
func typeOf[T any]() reflect.Type { return reflect.TypeOf((*T)(nil)).Elem() }

View File

@@ -0,0 +1,312 @@
package dashboardtypes
import (
"bytes"
"encoding/json"
"maps"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/go-playground/validator/v10"
"github.com/swaggest/jsonschema-go"
)
// ══════════════════════════════════════════════
// Panel plugin
// ══════════════════════════════════════════════
type PanelPlugin struct {
Kind PanelPluginKind `json:"kind"`
Spec any `json:"spec"`
}
// PrepareJSONSchema drops the reflected struct shape (type: object, properties)
// from the envelope so that only the JSONSchemaOneOf result binds.
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (p *PanelPlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
factory, ok := panelPluginSpecs[PanelPluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown panel plugin kind %q; allowed values: %s", kind, allowedValuesForKind(slices.Sorted(maps.Keys(panelPluginSpecs))))
}
spec, err := decodeSpec(specJSON, factory(), kind)
if err != nil {
return err
}
p.Kind = PanelPluginKind(kind)
p.Spec = spec
return nil
}
func (PanelPlugin) JSONSchemaOneOf() []any {
return []any{
PanelPluginVariant[TimeSeriesPanelSpec]{Kind: string(PanelKindTimeSeries)},
PanelPluginVariant[BarChartPanelSpec]{Kind: string(PanelKindBarChart)},
PanelPluginVariant[NumberPanelSpec]{Kind: string(PanelKindNumber)},
PanelPluginVariant[PieChartPanelSpec]{Kind: string(PanelKindPieChart)},
PanelPluginVariant[TablePanelSpec]{Kind: string(PanelKindTable)},
PanelPluginVariant[HistogramPanelSpec]{Kind: string(PanelKindHistogram)},
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
}
}
type PanelPluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v PanelPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}
// ══════════════════════════════════════════════
// Query plugin
// ══════════════════════════════════════════════
type QueryPlugin struct {
Kind QueryPluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
factory, ok := queryPluginSpecs[QueryPluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown query plugin kind %q; allowed values: %s", kind, allowedValuesForKind(slices.Sorted(maps.Keys(queryPluginSpecs))))
}
spec, err := decodeSpec(specJSON, factory(), kind)
if err != nil {
return err
}
p.Kind = QueryPluginKind(kind)
p.Spec = spec
return nil
}
func (QueryPlugin) JSONSchemaOneOf() []any {
return []any{
QueryPluginVariant[BuilderQuerySpec]{Kind: string(QueryKindBuilder)},
QueryPluginVariant[CompositeQuerySpec]{Kind: string(QueryKindComposite)},
QueryPluginVariant[FormulaSpec]{Kind: string(QueryKindFormula)},
QueryPluginVariant[PromQLQuerySpec]{Kind: string(QueryKindPromQL)},
QueryPluginVariant[ClickHouseSQLQuerySpec]{Kind: string(QueryKindClickHouseSQL)},
QueryPluginVariant[TraceOperatorSpec]{Kind: string(QueryKindTraceOperator)},
}
}
type QueryPluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v QueryPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}
// ══════════════════════════════════════════════
// Variable plugin
// ══════════════════════════════════════════════
type VariablePlugin struct {
Kind VariablePluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (VariablePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (p *VariablePlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
factory, ok := variablePluginSpecs[VariablePluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown variable plugin kind %q; allowed values: %s", kind, allowedValuesForKind(slices.Sorted(maps.Keys(variablePluginSpecs))))
}
spec, err := decodeSpec(specJSON, factory(), kind)
if err != nil {
return err
}
p.Kind = VariablePluginKind(kind)
p.Spec = spec
return nil
}
func (VariablePlugin) JSONSchemaOneOf() []any {
return []any{
VariablePluginVariant[DynamicVariableSpec]{Kind: string(VariableKindDynamic)},
VariablePluginVariant[QueryVariableSpec]{Kind: string(VariableKindQuery)},
VariablePluginVariant[CustomVariableSpec]{Kind: string(VariableKindCustom)},
}
}
type VariablePluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}
// ══════════════════════════════════════════════
// Datasource plugin
// ══════════════════════════════════════════════
type DatasourcePlugin struct {
Kind DatasourcePluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (DatasourcePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
factory, ok := datasourcePluginSpecs[DatasourcePluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown datasource plugin kind %q; allowed values: %s", kind, allowedValuesForKind(slices.Sorted(maps.Keys(datasourcePluginSpecs))))
}
spec, err := decodeSpec(specJSON, factory(), kind)
if err != nil {
return err
}
p.Kind = DatasourcePluginKind(kind)
p.Spec = spec
return nil
}
func (DatasourcePlugin) JSONSchemaOneOf() []any {
return []any{
DatasourcePluginVariant[struct{}]{Kind: string(DatasourceKindSigNoz)},
}
}
type DatasourcePluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v DatasourcePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}
// ══════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
QueryKindFormula: func() any { return new(FormulaSpec) },
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
QueryKindClickHouseSQL: func() any { return new(ClickHouseSQLQuerySpec) },
QueryKindTraceOperator: func() any { return new(TraceOperatorSpec) },
}
variablePluginSpecs = map[VariablePluginKind]func() any{
VariableKindDynamic: func() any { return new(DynamicVariableSpec) },
VariableKindQuery: func() any { return new(QueryVariableSpec) },
VariableKindCustom: func() any { return new(CustomVariableSpec) },
}
datasourcePluginSpecs = map[DatasourcePluginKind]func() any{
DatasourceKindSigNoz: func() any { return new(struct{}) },
}
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
}
)
func allowedValuesForKind[K ~string](kinds []K) string {
parts := make([]string, len(kinds))
for i, k := range kinds {
parts[i] = "`" + string(k) + "`"
}
return strings.Join(parts, ", ")
}
// extractKindAndSpec parses a {"kind": "...", "spec": {...}} envelope and returns
// kind and the raw spec bytes for typed decoding.
func extractKindAndSpec(data []byte) (string, []byte, error) {
var head struct {
Kind string `json:"kind"`
Spec json.RawMessage `json:"spec"`
}
if err := json.Unmarshal(data, &head); err != nil {
return "", nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid plugin envelope")
}
return head.Kind, head.Spec, nil
}
// decodeSpec strict-decodes a spec JSON into target and runs struct-tag validation (go-playground/validator).
func decodeSpec(specJSON []byte, target any, kind string) (any, error) {
if len(specJSON) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "kind %q: spec is required", kind)
}
dec := json.NewDecoder(bytes.NewReader(specJSON))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: invalid spec JSON", kind)
}
if err := validator.New().Struct(target); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: spec failed validation", kind)
}
return target, nil
}
// clearOneOfParentShape drops Type and Properties on a schema that also has a JSONSchemaOneOf.
func clearOneOfParentShape(s *jsonschema.Schema) error {
s.Type = nil
s.Properties = nil
return nil
}
// restrictKindToOneValue ensures that the schema only allows one Kind value for a type.
// For eg. PanelPluginVariant[TimeSeriesPanelSpec]{Kind: string(PanelKindTimeSeries)} should
// only allow "signoz/TimeSeriesPanel" in its kind field.
func restrictKindToOneValue(schema *jsonschema.Schema, kind string) error {
kindProp, ok := schema.Properties["kind"]
if !ok || kindProp.TypeObject == nil {
return errors.NewInternalf(errors.CodeInternal, "variant schema missing `kind` property")
}
kindProp.TypeObject.WithEnum(kind)
schema.Properties["kind"] = kindProp
return nil
}

View File

@@ -0,0 +1,182 @@
package dashboardtypes
import (
"maps"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
"github.com/perses/perses/pkg/model/api/v1/variable"
"github.com/swaggest/jsonschema-go"
)
// ══════════════════════════════════════════════
// Datasource
// ══════════════════════════════════════════════
type DatasourceSpec struct {
Display *common.Display `json:"display,omitempty"`
Default bool `json:"default"`
Plugin DatasourcePlugin `json:"plugin"`
}
// ══════════════════════════════════════════════
// Panel
// ══════════════════════════════════════════════
type Panel struct {
Kind string `json:"kind"`
Spec PanelSpec `json:"spec"`
}
type PanelSpec struct {
Display *v1.PanelDisplay `json:"display,omitempty"`
Plugin PanelPlugin `json:"plugin"`
Queries []Query `json:"queries,omitempty"`
Links []v1.Link `json:"links,omitempty"`
}
// ══════════════════════════════════════════════
// Query
// ══════════════════════════════════════════════
type Query struct {
Kind string `json:"kind"`
Spec QuerySpec `json:"spec"`
}
type QuerySpec struct {
Name string `json:"name,omitempty"`
Plugin QueryPlugin `json:"plugin"`
}
// ══════════════════════════════════════════════
// Variable
// ══════════════════════════════════════════════
// Variable is the list/text sum type. Spec is set to *ListVariableSpec or
// *dashboard.TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// discriminated oneOf (see JSONSchemaOneOf).
type Variable struct {
Kind variable.Kind `json:"kind"`
Spec any `json:"spec"`
}
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (v *Variable) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
switch kind {
case string(variable.KindList):
spec, err := decodeSpec(specJSON, new(ListVariableSpec), kind)
if err != nil {
return err
}
v.Kind = variable.KindList
v.Spec = spec
case string(variable.KindText):
spec, err := decodeSpec(specJSON, new(dashboard.TextVariableSpec), kind)
if err != nil {
return err
}
v.Kind = variable.KindText
v.Spec = spec
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown variable kind %q; allowed values: %s", kind, allowedValuesForKind([]variable.Kind{variable.KindList, variable.KindText}))
}
return nil
}
func (Variable) JSONSchemaOneOf() []any {
return []any{
VariableEnvelope[ListVariableSpec]{Kind: string(variable.KindList)},
VariableEnvelope[dashboard.TextVariableSpec]{Kind: string(variable.KindText)},
}
}
type VariableEnvelope[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
type ListVariableSpec struct {
Display *variable.Display `json:"display,omitempty"`
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
AllowAllValue bool `json:"allowAllValue"`
AllowMultiple bool `json:"allowMultiple"`
CustomAllValue string `json:"customAllValue,omitempty"`
CapturingRegexp string `json:"capturingRegexp,omitempty"`
Sort *variable.Sort `json:"sort,omitempty"`
Plugin VariablePlugin `json:"plugin"`
Name string `json:"name"`
}
// ══════════════════════════════════════════════
// Layout
// ══════════════════════════════════════════════
// Layout is the dashboard layout sum type. Spec is populated by UnmarshalJSON
// with the concrete layout spec struct (today only dashboard.GridLayoutSpec)
// based on Kind. No plugin is involved, so we reuse the Perses spec types as
// leaf imports.
type Layout struct {
Kind dashboard.LayoutKind `json:"kind"`
Spec any `json:"spec"`
}
// layoutSpecs is the layout sum type factory. Perses only defines
// KindGridLayout today; adding a new kind upstream surfaces as an
// "unknown layout kind" runtime error here until we add it.
var layoutSpecs = map[dashboard.LayoutKind]func() any{
dashboard.KindGridLayout: func() any { return new(dashboard.GridLayoutSpec) },
}
func (Layout) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (l *Layout) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
factory, ok := layoutSpecs[dashboard.LayoutKind(kind)]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown layout kind %q; allowed values: %s", kind, allowedValuesForKind(slices.Sorted(maps.Keys(layoutSpecs))))
}
spec, err := decodeSpec(specJSON, factory(), kind)
if err != nil {
return err
}
l.Kind = dashboard.LayoutKind(kind)
l.Spec = spec
return nil
}
func (Layout) JSONSchemaOneOf() []any {
return []any{
LayoutEnvelope[dashboard.GridLayoutSpec]{Kind: string(dashboard.KindGridLayout)},
}
}
type LayoutEnvelope[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v LayoutEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}

View File

@@ -8,6 +8,7 @@ import (
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
)
// ══════════════════════════════════════════════
@@ -20,11 +21,10 @@ const (
VariableKindDynamic VariablePluginKind = "signoz/DynamicVariable"
VariableKindQuery VariablePluginKind = "signoz/QueryVariable"
VariableKindCustom VariablePluginKind = "signoz/CustomVariable"
VariableKindTextbox VariablePluginKind = "signoz/TextboxVariable"
)
func (VariablePluginKind) Enum() []any {
return []any{VariableKindDynamic, VariableKindQuery, VariableKindCustom, VariableKindTextbox}
return []any{VariableKindDynamic, VariableKindQuery, VariableKindCustom}
}
type DynamicVariableSpec struct {
@@ -42,8 +42,6 @@ type CustomVariableSpec struct {
CustomValue string `json:"customValue" validate:"required" required:"true"`
}
type TextboxVariableSpec struct{}
// ══════════════════════════════════════════════
// SigNoz query plugin specs — aliased from querybuildertypesv5
// ══════════════════════════════════════════════
@@ -87,6 +85,30 @@ func (b *BuilderQuerySpec) UnmarshalJSON(data []byte) error {
return nil
}
// MarshalJSON delegates to the inner Spec so the on-wire shape matches what
// UnmarshalJSON expects (a flat builder-query payload with `signal` at the top
// level). Without this, Go's default would wrap it as {"Spec": {...}} and the
// signal-dispatch on read would fail.
func (b BuilderQuerySpec) MarshalJSON() ([]byte, error) {
return json.Marshal(b.Spec)
}
// PrepareJSONSchema drops the reflected struct shape so only the
// JSONSchemaOneOf result binds.
func (BuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
// JSONSchemaOneOf exposes the three signal-dispatched shapes a builder query
// can take. Mirrors qb.UnmarshalBuilderQueryBySignal's runtime dispatch.
func (BuilderQuerySpec) JSONSchemaOneOf() []any {
return []any{
qb.QueryBuilderQuery[qb.LogAggregation]{},
qb.QueryBuilderQuery[qb.MetricAggregation]{},
qb.QueryBuilderQuery[qb.TraceAggregation]{},
}
}
// ══════════════════════════════════════════════
// SigNoz panel plugin specs
// ══════════════════════════════════════════════

View File

@@ -2,6 +2,7 @@ package dashboardtypes
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -32,4 +33,23 @@ type Store interface {
DeletePublic(context.Context, string) error
RunInTx(context.Context, func(context.Context) error) error
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
GetV2(context.Context, valuer.UUID, valuer.UUID) (*StorableDashboard, *StorablePublicDashboard, error)
// UpdateV2 updates the dashboard's data, updated_at and updated_by columns
// only, scoped by org and excluding soft-deleted rows. Uses the caller's
// transaction context so it can be made atomic with tag relation changes.
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data StorableDashboardData) error
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, locked bool, updatedBy string) error
//re-deleting a soft-deleted row returns 0 rows → NotFound.
SoftDeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, deletedBy string) error
ListPurgeable(ctx context.Context, retention time.Duration, limit int) ([]valuer.UUID, error)
HardDelete(ctx context.Context, ids []valuer.UUID) error
}

View File

@@ -76,11 +76,7 @@
"display": {
"name": "textboxvar"
},
"value": "defaultvaluegoeshere",
"plugin": {
"kind": "signoz/TextboxVariable",
"spec": {}
}
"value": "defaultvaluegoeshere"
}
}
],

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