mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-23 06:20:37 +01:00
Compare commits
101 Commits
main
...
nv/integra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34c4b385a4 | ||
|
|
36ee71298f | ||
|
|
2161ea9ef0 | ||
|
|
61700665f6 | ||
|
|
8ad07d758c | ||
|
|
c7cfc58a36 | ||
|
|
9bfd293774 | ||
|
|
f26681091d | ||
|
|
d798164da9 | ||
|
|
4f8c555eef | ||
|
|
e5eb47e360 | ||
|
|
474ecba392 | ||
|
|
c7d116b130 | ||
|
|
b611e3adc7 | ||
|
|
598f466682 | ||
|
|
7a29fdbcaf | ||
|
|
8fe094a2a4 | ||
|
|
527cc5b002 | ||
|
|
dbbaa4d037 | ||
|
|
e818e667a2 | ||
|
|
e390eef74b | ||
|
|
a6490661d1 | ||
|
|
e8883c4f65 | ||
|
|
aed3d096f0 | ||
|
|
5ffe4ec1f8 | ||
|
|
3e095b710b | ||
|
|
c11061b0ae | ||
|
|
c413d17594 | ||
|
|
9f8b1be83b | ||
|
|
f0d54cedd4 | ||
|
|
be56b929b4 | ||
|
|
20019d835c | ||
|
|
c49396944b | ||
|
|
39d6880cf7 | ||
|
|
3f20c04c97 | ||
|
|
70e556bd38 | ||
|
|
0a59a8eb04 | ||
|
|
6fee60833a | ||
|
|
b81bb42be6 | ||
|
|
d8717ee466 | ||
|
|
d0d81b778f | ||
|
|
b6a0431049 | ||
|
|
54560b6f63 | ||
|
|
41a68cd17e | ||
|
|
af96aef5e1 | ||
|
|
171f95ac25 | ||
|
|
93387b38d2 | ||
|
|
d4384eba1b | ||
|
|
6151467b3e | ||
|
|
daa999d1d6 | ||
|
|
b400846193 | ||
|
|
f5220d078f | ||
|
|
ab750ffffd | ||
|
|
4a2b508636 | ||
|
|
33795999cf | ||
|
|
9aac37eb94 | ||
|
|
12642e27da | ||
|
|
82f8bf88fc | ||
|
|
3befc9e8c6 | ||
|
|
ee5b85f7a4 | ||
|
|
a05164f225 | ||
|
|
35e4013826 | ||
|
|
5884c6aa90 | ||
|
|
533a430714 | ||
|
|
ba6af34714 | ||
|
|
851c7b0ad7 | ||
|
|
ef5a67495c | ||
|
|
9f540ca84b | ||
|
|
40a6b22aed | ||
|
|
6f16416f27 | ||
|
|
f8aa1c1c34 | ||
|
|
65835394c0 | ||
|
|
f132b7e53a | ||
|
|
d4ae156dc4 | ||
|
|
d6bdf9c2b2 | ||
|
|
7ea654f1aa | ||
|
|
3fd7d013a1 | ||
|
|
fb921dd381 | ||
|
|
58020d9e00 | ||
|
|
7a5933e822 | ||
|
|
2533683de6 | ||
|
|
2670d53170 | ||
|
|
8943a9454b | ||
|
|
9a7ed5b711 | ||
|
|
2d75e3d32d | ||
|
|
1d6eabf927 | ||
|
|
082d7b1b77 | ||
|
|
5019dee2d7 | ||
|
|
216de973fb | ||
|
|
18c0eec5e2 | ||
|
|
2ccdeb3631 | ||
|
|
ad12e50bbc | ||
|
|
e247bf3864 | ||
|
|
f4651ea134 | ||
|
|
d449a2dbf2 | ||
|
|
d4b9f91062 | ||
|
|
530710b7bc | ||
|
|
4fb5eec08d | ||
|
|
f889d36f0f | ||
|
|
db12d44523 | ||
|
|
86fc0e81ba |
211
cmd/dashboardmigrateintegrations/main.go
Normal file
211
cmd/dashboardmigrateintegrations/main.go
Normal file
@@ -0,0 +1,211 @@
|
||||
// Command dashboardmigrateintegrations runs the v1→v2 dashboard migration over
|
||||
// every bundled integration dashboard in this repo — the built-in integrations
|
||||
// under pkg/query-service/app/integrations/builtin_integrations and the cloud
|
||||
// integrations under pkg/modules/cloudintegration/.../fs/definitions — to surface
|
||||
// conversion/validation gaps and rewrite the dashboards to the v2 schema.
|
||||
//
|
||||
// It mirrors the production pipeline (module.ConvertAllV1ToV2): run the v4→v5
|
||||
// widget-query migration in place, then StorableDashboard.ConvertV1ToV2, then
|
||||
// PostableDashboardV2.Validate.
|
||||
//
|
||||
// Unlike dashboardmigraterepo (which reviews an external checkout via -out), these
|
||||
// dashboards live in this repo, so migrated output overwrites each file in place by
|
||||
// default — review the result with `git diff`. Pass -out to mirror the migrated
|
||||
// copies into a separate directory instead, leaving the repo untouched.
|
||||
//
|
||||
// Only files matching <root>/.../assets/dashboards/*.json count as dashboards;
|
||||
// integration.json, config, and other assets are skipped, as are the frozen
|
||||
// pkg/sqlmigration snapshot copies (they live outside the scanned roots).
|
||||
//
|
||||
// Throwaway tooling for the schema migration; not part of the build.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/dashboardmigrateintegrations # overwrite in place
|
||||
// go run ./cmd/dashboardmigrateintegrations -out /tmp/v2 # review copies, repo untouched
|
||||
// go run ./cmd/dashboardmigrateintegrations -only redis # just the redis dashboards
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// integrationDashboardRoots are the two live sources of bundled integration
|
||||
// dashboards, relative to -in. The pkg/sqlmigration snapshot copies are
|
||||
// deliberately excluded — they are frozen inputs to one-time DB migrations.
|
||||
var integrationDashboardRoots = []string{
|
||||
"pkg/query-service/app/integrations/builtin_integrations",
|
||||
"pkg/modules/cloudintegration/implcloudintegration/fs/definitions",
|
||||
}
|
||||
|
||||
type outcome struct {
|
||||
relPath string
|
||||
status string // ok | skipped-v2 | convert-failed | validate-failed | read-failed | write-failed
|
||||
detail string
|
||||
}
|
||||
|
||||
func main() {
|
||||
inDir := flag.String("in", ".", "repo root to scan for integration dashboards")
|
||||
outDir := flag.String("out", "", "directory to write migrated v2 JSON (mirrors -in layout); empty = overwrite each dashboard in place")
|
||||
only := flag.String("only", "", "restrict to dashboards whose path contains this substring (e.g. redis or aws/rds); empty = all")
|
||||
flag.Parse()
|
||||
|
||||
ctx := context.Background()
|
||||
// nil duplicate-key lists == the create path / ConvertAllV1ToV2 wiring.
|
||||
migrator := transition.NewDashboardMigrateV5(slog.New(slog.NewTextHandler(os.Stderr, nil)), nil, nil)
|
||||
|
||||
var outcomes []outcome
|
||||
for _, root := range integrationDashboardRoots {
|
||||
walkRoot := filepath.Join(*inDir, root)
|
||||
err := filepath.WalkDir(walkRoot, func(path string, dirEntry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dirEntry.IsDir() || !isDashboardFile(path) {
|
||||
return nil
|
||||
}
|
||||
// rel is always computed against -in so -out mirrors the repo layout.
|
||||
rel, _ := filepath.Rel(*inDir, path)
|
||||
if *only != "" && !strings.Contains(rel, *only) {
|
||||
return nil
|
||||
}
|
||||
outcomes = append(outcomes, migrateOne(ctx, migrator, path, rel, *outDir))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "walk %s failed: %v\n", walkRoot, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
report(outcomes)
|
||||
}
|
||||
|
||||
// isDashboardFile picks out only the dashboard JSONs — those under an
|
||||
// assets/dashboards/ directory — leaving integration.json, config, and other
|
||||
// per-integration assets untouched.
|
||||
func isDashboardFile(path string) bool {
|
||||
return strings.HasSuffix(path, ".json") &&
|
||||
strings.Contains(filepath.ToSlash(path), "/assets/dashboards/")
|
||||
}
|
||||
|
||||
func migrateOne(ctx context.Context, migrator interface {
|
||||
Migrate(context.Context, map[string]any) bool
|
||||
}, path, rel, outDir string) outcome {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return outcome{rel, "read-failed", err.Error()}
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return outcome{rel, "read-failed", err.Error()}
|
||||
}
|
||||
|
||||
storable := dashboardtypes.StorableDashboard{
|
||||
Data: dashboardtypes.StorableDashboardData(data),
|
||||
OrgID: valuer.GenerateUUID(),
|
||||
}
|
||||
storable.ID = valuer.GenerateUUID()
|
||||
|
||||
if storable.IsV2() {
|
||||
return outcome{rel, "skipped-v2", "already v2 schema"}
|
||||
}
|
||||
|
||||
// v1→v2 assumes v5-shaped widget queries; run v4→v5 in place first.
|
||||
migrator.Migrate(ctx, storable.Data)
|
||||
|
||||
v2, err := storable.ConvertV1ToV2()
|
||||
if err != nil {
|
||||
return outcome{rel, "convert-failed", err.Error()}
|
||||
}
|
||||
|
||||
out, err := marshalPostableV2(v2)
|
||||
if err != nil {
|
||||
return outcome{rel, "convert-failed", err.Error()}
|
||||
}
|
||||
|
||||
// Validate exactly as the import API does: unmarshal the JSON back. This both
|
||||
// populates common.JSONRef.Path (json:"-", set only on decode — an in-memory
|
||||
// Spec.Validate() would spuriously fail panel-ref checks) and runs the full
|
||||
// PostableDashboardV2.Validate (DisallowUnknownFields + spec validation).
|
||||
var roundTrip dashboardtypes.PostableDashboardV2
|
||||
if err := json.Unmarshal(out, &roundTrip); err != nil {
|
||||
return outcome{rel, "validate-failed", err.Error()}
|
||||
}
|
||||
|
||||
// Overwrite in place by default; mirror into -out (same layout, same name) when set.
|
||||
dst := path
|
||||
if outDir != "" {
|
||||
dst = filepath.Join(outDir, rel)
|
||||
}
|
||||
if err := writeFile(out, dst); err != nil {
|
||||
return outcome{rel, "write-failed", err.Error()}
|
||||
}
|
||||
return outcome{rel, "ok", ""}
|
||||
}
|
||||
|
||||
// marshalPostableV2 renders the PostableDashboardV2 form (schemaVersion, image,
|
||||
// tags, spec) — the shape the provisioning path decodes.
|
||||
//
|
||||
// generateName is set (and name left empty) so the committed files stay
|
||||
// deterministic — no baked-in random suffix that would churn on every re-run —
|
||||
// and each install generates a fresh per-org dashboard name from spec.display.name.
|
||||
func marshalPostableV2(v2 *dashboardtypes.DashboardV2) ([]byte, error) {
|
||||
postable := dashboardtypes.PostableDashboardV2{
|
||||
DashboardV2MetadataBase: v2.DashboardV2MetadataBase,
|
||||
GenerateName: true,
|
||||
Tags: tagtypes.NewPostableTagsFromTags(v2.Tags),
|
||||
Spec: v2.Spec,
|
||||
}
|
||||
return json.MarshalIndent(postable, "", " ")
|
||||
}
|
||||
|
||||
func writeFile(out []byte, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, out, 0o644)
|
||||
}
|
||||
|
||||
func report(outcomes []outcome) {
|
||||
sort.Slice(outcomes, func(i, j int) bool { return outcomes[i].relPath < outcomes[j].relPath })
|
||||
|
||||
counts := map[string]int{}
|
||||
for _, o := range outcomes {
|
||||
counts[o.status]++
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== %d dashboards ===\n", len(outcomes))
|
||||
for _, status := range []string{"ok", "skipped-v2", "convert-failed", "validate-failed", "read-failed", "write-failed"} {
|
||||
if counts[status] > 0 {
|
||||
fmt.Printf(" %-16s %d\n", status, counts[status])
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== failures ===\n")
|
||||
any := false
|
||||
for _, o := range outcomes {
|
||||
if o.status == "ok" || o.status == "skipped-v2" {
|
||||
continue
|
||||
}
|
||||
any = true
|
||||
fmt.Printf("\n[%s] %s\n %s\n", o.status, o.relPath, o.detail)
|
||||
}
|
||||
if !any {
|
||||
fmt.Println(" none")
|
||||
}
|
||||
}
|
||||
@@ -551,12 +551,12 @@ func (module *module) provisionDashboards(ctx context.Context, orgID valuer.UUID
|
||||
continue
|
||||
}
|
||||
|
||||
createdDashboard, err := module.dashboardModule.Create(ctx, orgID, createdBy, creator, dashboardtypes.SourceIntegration, dashboardtypes.PostableDashboard(dashboard.Definition))
|
||||
createdDashboard, err := module.dashboardModule.CreateV2(ctx, orgID, createdBy, creator, dashboardtypes.SourceIntegration, dashboard.Definition)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
integrationDashboard := cloudintegrationtypes.NewStorableIntegrationDashboard(createdDashboard.ID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
integrationDashboard := cloudintegrationtypes.NewStorableIntegrationDashboard(createdDashboard.ID.StringValue(), cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
if err := module.store.CreateIntegrationDashboard(ctx, integrationDashboard); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -151,20 +151,15 @@ func readBuiltInIntegration(dirpath string) (
|
||||
|
||||
func validateIntegration(i IntegrationDetails) error {
|
||||
// Validate dashboard data
|
||||
seenDashboardIds := map[string]interface{}{}
|
||||
seenDashboardIds := map[string]struct{}{}
|
||||
for _, dd := range i.Assets.Dashboards {
|
||||
did, exists := dd["id"]
|
||||
if !exists {
|
||||
return fmt.Errorf("id is required. not specified in dashboard titled %v", dd["title"])
|
||||
if dd.ID == "" {
|
||||
return fmt.Errorf("id is required for dashboard %q in integration %s", dd.Definition.Spec.Display.Name, i.Id)
|
||||
}
|
||||
dashboardId, ok := did.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("id must be string in dashboard titled %v", dd["title"])
|
||||
if _, seen := seenDashboardIds[dd.ID]; seen {
|
||||
return fmt.Errorf("multiple dashboards found with id %s", dd.ID)
|
||||
}
|
||||
if _, seen := seenDashboardIds[dashboardId]; seen {
|
||||
return fmt.Errorf("multiple dashboards found with id %s", dashboardId)
|
||||
}
|
||||
seenDashboardIds[dashboardId] = nil
|
||||
seenDashboardIds[dd.ID] = struct{}{}
|
||||
}
|
||||
|
||||
// TODO(Raj): Validate all parts of plugged in integrations
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,10 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
"file://assets/dashboards/overview.json"
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -31,8 +31,14 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
"file://assets/dashboards/overview.json",
|
||||
"file://assets/dashboards/db_metrics_overview.json"
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
},
|
||||
{
|
||||
"id": "db_metrics_overview",
|
||||
"definition": "file://assets/dashboards/db_metrics_overview.json"
|
||||
}
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -31,8 +31,14 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
"file://assets/dashboards/overview.json",
|
||||
"file://assets/dashboards/db_metrics_overview.json"
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
},
|
||||
{
|
||||
"id": "db_metrics_overview",
|
||||
"definition": "file://assets/dashboards/db_metrics_overview.json"
|
||||
}
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,10 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
"file://assets/dashboards/overview.json"
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,10 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
"file://assets/dashboards/overview.json"
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,10 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
"file://assets/dashboards/overview.json"
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,10 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
"file://assets/dashboards/overview.json"
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
@@ -34,12 +34,21 @@ type IntegrationSummary struct {
|
||||
}
|
||||
|
||||
type IntegrationAssets struct {
|
||||
Logs LogsAssets `json:"logs"`
|
||||
Dashboards []dashboardtypes.StorableDashboardData `json:"dashboards"`
|
||||
Logs LogsAssets `json:"logs"`
|
||||
Dashboards []Dashboard `json:"dashboards"`
|
||||
|
||||
Alerts []ruletypes.PostableRule `json:"alerts"`
|
||||
}
|
||||
|
||||
// Dashboard pairs a built-in dashboard's stable id with its v2 definition. The id
|
||||
// lives in integration.json alongside the definition file reference (mirroring the
|
||||
// cloud integration model) and drives the install slug, so it stays stable even
|
||||
// though the v2 definition blob no longer carries a top-level id.
|
||||
type Dashboard struct {
|
||||
ID string `json:"id"`
|
||||
Definition dashboardtypes.PostableDashboardV2 `json:"definition"`
|
||||
}
|
||||
|
||||
type LogsAssets struct {
|
||||
Pipelines []pipelinetypes.PostablePipeline `json:"pipelines"`
|
||||
}
|
||||
@@ -417,23 +426,22 @@ func (m *Manager) provisionDashboards(
|
||||
) error {
|
||||
bareIntegrationID := strings.TrimPrefix(integrationID, "builtin-")
|
||||
for _, dd := range integration.Assets.Dashboards {
|
||||
dashID, _ := dd["id"].(string)
|
||||
if dashID == "" {
|
||||
if dd.ID == "" {
|
||||
continue
|
||||
}
|
||||
slug := cloudintegrationtypes.InstalledIntegrationDashboardSlug(bareIntegrationID, dashID)
|
||||
slug := cloudintegrationtypes.InstalledIntegrationDashboardSlug(bareIntegrationID, dd.ID)
|
||||
|
||||
existing, err := m.installedIntegrationsRepo.getIntegrationDashboardBySlug(ctx, orgID.StringValue(), slug)
|
||||
if err == nil && existing != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
createdDashboard, err := m.dashboardModule.Create(ctx, orgID, createdBy, creator, dashboardtypes.SourceIntegration, dashboardtypes.PostableDashboard(dd))
|
||||
createdDashboard, err := m.dashboardModule.CreateV2(ctx, orgID, createdBy, creator, dashboardtypes.SourceIntegration, dd.Definition)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create dashboard for slug %s: %w", slug, err)
|
||||
}
|
||||
|
||||
row := cloudintegrationtypes.NewStorableIntegrationDashboard(createdDashboard.ID, cloudintegrationtypes.IntegrationDashboardInstalledIntegrationProvider, slug)
|
||||
row := cloudintegrationtypes.NewStorableIntegrationDashboard(createdDashboard.ID.StringValue(), cloudintegrationtypes.IntegrationDashboardInstalledIntegrationProvider, slug)
|
||||
if err := m.installedIntegrationsRepo.createIntegrationDashboard(ctx, row); err != nil {
|
||||
return fmt.Errorf("could not create integration_dashboard row for slug %s: %w", slug, err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
type migrateCommon struct {
|
||||
@@ -23,119 +24,10 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
|
||||
}
|
||||
}
|
||||
|
||||
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
|
||||
// transform is stateless and shared with the v1→v2 dashboard conversion.
|
||||
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
|
||||
// Create a properly structured v5 query
|
||||
v5Query := map[string]any{
|
||||
"name": name,
|
||||
"disabled": queryMap["disabled"],
|
||||
"legend": queryMap["legend"],
|
||||
}
|
||||
|
||||
if name != queryMap["expression"] {
|
||||
// formula
|
||||
queryType = "builder_formula"
|
||||
v5Query["expression"] = queryMap["expression"]
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
}
|
||||
|
||||
// Add signal based on data source
|
||||
if dataSource, ok := queryMap["dataSource"].(string); ok {
|
||||
switch dataSource {
|
||||
case "traces":
|
||||
v5Query["signal"] = "traces"
|
||||
case "logs":
|
||||
v5Query["signal"] = "logs"
|
||||
case "metrics":
|
||||
v5Query["signal"] = "metrics"
|
||||
}
|
||||
}
|
||||
|
||||
if stepInterval, ok := queryMap["stepInterval"]; ok {
|
||||
v5Query["stepInterval"] = stepInterval
|
||||
}
|
||||
|
||||
if aggregations, ok := queryMap["aggregations"]; ok {
|
||||
v5Query["aggregations"] = aggregations
|
||||
}
|
||||
|
||||
if filter, ok := queryMap["filter"]; ok {
|
||||
v5Query["filter"] = filter
|
||||
}
|
||||
|
||||
// Copy groupBy with proper structure
|
||||
if groupBy, ok := queryMap["groupBy"].([]any); ok {
|
||||
v5GroupBy := make([]any, len(groupBy))
|
||||
for i, gb := range groupBy {
|
||||
if gbMap, ok := gb.(map[string]any); ok {
|
||||
v5GroupBy[i] = map[string]any{
|
||||
"name": gbMap["key"],
|
||||
"fieldDataType": gbMap["dataType"],
|
||||
"fieldContext": gbMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["groupBy"] = v5GroupBy
|
||||
}
|
||||
|
||||
// Copy orderBy with proper structure
|
||||
if orderBy, ok := queryMap["orderBy"].([]any); ok {
|
||||
v5OrderBy := make([]any, len(orderBy))
|
||||
for i, ob := range orderBy {
|
||||
if obMap, ok := ob.(map[string]any); ok {
|
||||
v5OrderBy[i] = map[string]any{
|
||||
"key": map[string]any{
|
||||
"name": obMap["columnName"],
|
||||
"fieldDataType": obMap["dataType"],
|
||||
"fieldContext": obMap["type"],
|
||||
},
|
||||
"direction": obMap["order"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["order"] = v5OrderBy
|
||||
}
|
||||
|
||||
// Copy selectColumns as selectFields
|
||||
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
|
||||
v5SelectFields := make([]any, len(selectColumns))
|
||||
for i, col := range selectColumns {
|
||||
if colMap, ok := col.(map[string]any); ok {
|
||||
v5SelectFields[i] = map[string]any{
|
||||
"name": colMap["key"],
|
||||
"fieldDataType": colMap["dataType"],
|
||||
"fieldContext": colMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["selectFields"] = v5SelectFields
|
||||
}
|
||||
|
||||
// Copy limit and offset
|
||||
if limit, ok := queryMap["limit"]; ok {
|
||||
v5Query["limit"] = limit
|
||||
}
|
||||
if offset, ok := queryMap["offset"]; ok {
|
||||
v5Query["offset"] = offset
|
||||
}
|
||||
|
||||
if having, ok := queryMap["having"]; ok {
|
||||
v5Query["having"] = having
|
||||
}
|
||||
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
|
||||
}
|
||||
|
||||
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {
|
||||
|
||||
353
pkg/transition/migrate_shape_safe.go
Normal file
353
pkg/transition/migrate_shape_safe.go
Normal file
@@ -0,0 +1,353 @@
|
||||
// nolint
|
||||
package transition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Shape-safe (idempotent) migration
|
||||
// ══════════════════════════════════════════════
|
||||
//
|
||||
// A copy of the Migrate → updateWidget → updateQueryData chain with the
|
||||
// "uniformly v4 input" assumption removed, so it is safe on a dashboard whose
|
||||
// `version` tag lies (a "v5"-labelled dashboard with un-upgraded, possibly mixed,
|
||||
// bodies — the v1→v2 converter's case). Versus the original: no version gate, and
|
||||
// each step acts only on the pre-v5 shape (leaving a v5 field alone), so it is
|
||||
// idempotent. The original Migrate is left unchanged (battle-tested, no test net).
|
||||
// The *ShapeSafe methods below each note the original they copy; the reused steps
|
||||
// (createFilterExpression, fixGroupBy, buildAggregationExpression, orderByExpr) are
|
||||
// already v5-safe.
|
||||
|
||||
// MigrateQueryDataShapeSafe is the per-query entry point (the core of
|
||||
// updateQueryDataShapeSafe) for callers that process queries one at a time (the
|
||||
// v1→v2 converter). widgetType is the v1 panelTypes (metric reduceTo on tables);
|
||||
// "" is safe.
|
||||
func (m *dashboardMigrateV5) MigrateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
|
||||
return m.updateQueryDataShapeSafe(ctx, queryData, widgetType)
|
||||
}
|
||||
|
||||
// updateQueryDataShapeSafe copies updateQueryData, with each destructive step
|
||||
// guarded to act only on the pre-v5 shape (see the file header).
|
||||
func (mc *migrateCommon) updateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
|
||||
updated := false
|
||||
|
||||
aggregateOp, _ := queryData["aggregateOperator"].(string)
|
||||
hasAggregation := aggregateOp != "" && aggregateOp != "noop"
|
||||
|
||||
if mc.createAggregationsShapeSafe(ctx, queryData, widgetType) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
// createFilterExpression only touches v4 `filters`; skip if a v5 `filter` exists.
|
||||
if _, hasFilter := queryData["filter"]; !hasFilter {
|
||||
if mc.createFilterExpression(ctx, queryData) {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
if mc.fixGroupBy(queryData) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if mc.createHavingExpressionShapeSafe(queryData) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if hasAggregation {
|
||||
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
|
||||
newOrderBy := make([]any, 0)
|
||||
for _, order := range orderBy {
|
||||
if orderMap, ok := order.(map[string]any); ok {
|
||||
columnName, _ := orderMap["columnName"].(string)
|
||||
// skip timestamp, id (logs, traces), samples(metrics) ordering for aggregation queries
|
||||
if columnName != "timestamp" && columnName != "samples" && columnName != "id" {
|
||||
if columnName == "#SIGNOZ_VALUE" {
|
||||
if expr, has := mc.orderByExpr(queryData); has {
|
||||
orderMap["columnName"] = expr
|
||||
}
|
||||
} else {
|
||||
// if the order by key is not part of the group by keys, remove it
|
||||
present := false
|
||||
|
||||
groupBy, ok := queryData["groupBy"].([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
for idx := range groupBy {
|
||||
item, ok := groupBy[idx].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, ok := item["key"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if key == columnName {
|
||||
present = true
|
||||
}
|
||||
}
|
||||
|
||||
if !present {
|
||||
mc.logger.WarnContext(ctx, "found a order by without group by, skipping", slog.String("order_col_name", columnName))
|
||||
continue
|
||||
}
|
||||
}
|
||||
newOrderBy = append(newOrderBy, orderMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
queryData["orderBy"] = newOrderBy
|
||||
updated = true
|
||||
}
|
||||
} else {
|
||||
dataSource, _ := queryData["dataSource"].(string)
|
||||
|
||||
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
|
||||
newOrderBy := make([]any, 0)
|
||||
for _, order := range orderBy {
|
||||
if orderMap, ok := order.(map[string]any); ok {
|
||||
columnName, _ := orderMap["columnName"].(string)
|
||||
// skip id and timestamp for (traces)
|
||||
if (columnName == "id" || columnName == "timestamp") && dataSource == "traces" {
|
||||
mc.logger.InfoContext(ctx, "skipping `id` order by for traces")
|
||||
continue
|
||||
}
|
||||
|
||||
// skip id for (logs)
|
||||
if (columnName == "id" || columnName == "timestamp") && dataSource == "logs" {
|
||||
mc.logger.InfoContext(ctx, "skipping `id`/`timestamp` order by for logs")
|
||||
continue
|
||||
}
|
||||
|
||||
newOrderBy = append(newOrderBy, orderMap)
|
||||
}
|
||||
}
|
||||
queryData["orderBy"] = newOrderBy
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
// Only the `&& functionsArePreV5(functions)` guard differs from updateQueryData.
|
||||
if functions, ok := queryData["functions"].([]any); ok && functionsArePreV5(functions) {
|
||||
v5Functions := make([]any, len(functions))
|
||||
for i, fn := range functions {
|
||||
if fnMap, ok := fn.(map[string]any); ok {
|
||||
v5Function := map[string]any{
|
||||
"name": fnMap["name"],
|
||||
}
|
||||
|
||||
// Convert args from v4 format to v5 FunctionArg format
|
||||
if args, ok := fnMap["args"].([]any); ok {
|
||||
v5Args := make([]any, len(args))
|
||||
for j, arg := range args {
|
||||
// In v4, args were just values. In v5, they are FunctionArg objects
|
||||
v5Args[j] = map[string]any{
|
||||
"name": "", // v4 didn't have named args
|
||||
"value": arg,
|
||||
}
|
||||
}
|
||||
v5Function["args"] = v5Args
|
||||
}
|
||||
|
||||
// Handle namedArgs if present (some functions might have used this)
|
||||
if namedArgs, ok := fnMap["namedArgs"].(map[string]any); ok {
|
||||
// Convert named args to the new format
|
||||
existingArgs, _ := v5Function["args"].([]any)
|
||||
if existingArgs == nil {
|
||||
existingArgs = []any{}
|
||||
}
|
||||
|
||||
for name, value := range namedArgs {
|
||||
existingArgs = append(existingArgs, map[string]any{
|
||||
"name": name,
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
v5Function["args"] = existingArgs
|
||||
}
|
||||
|
||||
v5Functions[i] = v5Function
|
||||
}
|
||||
}
|
||||
queryData["functions"] = v5Functions
|
||||
updated = true
|
||||
}
|
||||
|
||||
delete(queryData, "aggregateOperator")
|
||||
delete(queryData, "aggregateAttribute")
|
||||
delete(queryData, "temporality")
|
||||
delete(queryData, "timeAggregation")
|
||||
delete(queryData, "spaceAggregation")
|
||||
delete(queryData, "reduceTo")
|
||||
delete(queryData, "filters")
|
||||
delete(queryData, "ShiftBy")
|
||||
delete(queryData, "IsAnomaly")
|
||||
delete(queryData, "QueriesUsedInFormula")
|
||||
delete(queryData, "seriesAggregation")
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
// createHavingExpressionShapeSafe copies createHavingExpression but leaves an
|
||||
// already-v5 having:{expression} alone instead of wiping it.
|
||||
func (mc *migrateCommon) createHavingExpressionShapeSafe(queryData map[string]any) bool {
|
||||
if _, ok := queryData["having"].(map[string]any); ok {
|
||||
return false // already v5-shaped
|
||||
}
|
||||
having, ok := queryData["having"].([]any)
|
||||
if !ok || len(having) == 0 {
|
||||
queryData["having"] = map[string]any{"expression": ""}
|
||||
return true
|
||||
}
|
||||
|
||||
dataSource, _ := queryData["dataSource"].(string)
|
||||
|
||||
for idx := range having {
|
||||
if havingItem, ok := having[idx].(map[string]any); ok {
|
||||
havingCol, has := mc.orderByExpr(queryData)
|
||||
if has {
|
||||
havingItem["columnName"] = havingCol
|
||||
havingItem["key"] = map[string]any{"key": havingCol}
|
||||
}
|
||||
having[idx] = havingItem
|
||||
}
|
||||
}
|
||||
queryData["having"] = map[string]any{"expression": mc.buildExpression(context.Background(), having, "AND", dataSource)}
|
||||
return true
|
||||
}
|
||||
|
||||
// createAggregationsShapeSafe copies createAggregations but skips a query that
|
||||
// already has a v5 aggregations[], and picks the metric time/space aggregation
|
||||
// from the body's shape (has timeAggregation/spaceAggregation?) rather than the
|
||||
// version tag.
|
||||
func (mc *migrateCommon) createAggregationsShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
|
||||
if aggs, ok := queryData["aggregations"].([]any); ok && len(aggs) > 0 {
|
||||
return false // already v5-shaped
|
||||
}
|
||||
|
||||
aggregateOp, hasOp := queryData["aggregateOperator"].(string)
|
||||
aggregateAttr, hasAttr := queryData["aggregateAttribute"].(map[string]any)
|
||||
dataSource, _ := queryData["dataSource"].(string)
|
||||
|
||||
if aggregateOp == "noop" && dataSource != "metrics" {
|
||||
return false
|
||||
}
|
||||
if !hasOp || !hasAttr {
|
||||
return false
|
||||
}
|
||||
|
||||
var aggregation map[string]any
|
||||
|
||||
switch dataSource {
|
||||
case "metrics":
|
||||
_, hasTime := queryData["timeAggregation"]
|
||||
_, hasSpace := queryData["spaceAggregation"]
|
||||
if hasTime || hasSpace { // acts as a check for v4 shape: the body carries its own time/space aggregation.
|
||||
if _, ok := queryData["spaceAggregation"]; !ok {
|
||||
queryData["spaceAggregation"] = aggregateOp
|
||||
}
|
||||
aggregation = map[string]any{
|
||||
"metricName": aggregateAttr["key"],
|
||||
"temporality": queryData["temporality"],
|
||||
"timeAggregation": queryData["timeAggregation"],
|
||||
"spaceAggregation": queryData["spaceAggregation"],
|
||||
}
|
||||
if reduceTo, ok := queryData["reduceTo"].(string); ok {
|
||||
aggregation["reduceTo"] = reduceTo
|
||||
}
|
||||
} else {
|
||||
// v3 shape: derive time/space from the compound operator.
|
||||
var timeAgg, spaceAgg, reduceTo string
|
||||
switch aggregateOp {
|
||||
case "sum_rate", "rate_sum":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
|
||||
case "avg_rate", "rate_avg":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "avg", "avg"
|
||||
case "min_rate", "rate_min":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "min", "min"
|
||||
case "max_rate", "rate_max":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "max", "max"
|
||||
case "hist_quantile_50":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p50", "avg"
|
||||
case "hist_quantile_75":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p75", "avg"
|
||||
case "hist_quantile_90":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p90", "avg"
|
||||
case "hist_quantile_95":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p95", "avg"
|
||||
case "hist_quantile_99":
|
||||
timeAgg, spaceAgg, reduceTo = "", "p99", "avg"
|
||||
case "rate":
|
||||
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
|
||||
case "p99", "p90", "p75", "p50", "p25", "p20", "p10", "p05":
|
||||
mc.logger.InfoContext(ctx, "found invalid config")
|
||||
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
|
||||
case "min":
|
||||
timeAgg, spaceAgg, reduceTo = "min", "min", "min"
|
||||
case "max":
|
||||
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
|
||||
case "avg":
|
||||
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
|
||||
case "sum":
|
||||
timeAgg, spaceAgg, reduceTo = "sum", "sum", "sum"
|
||||
case "count":
|
||||
timeAgg, spaceAgg, reduceTo = "count", "sum", "sum"
|
||||
case "count_distinct":
|
||||
timeAgg, spaceAgg, reduceTo = "count_distinct", "sum", "sum"
|
||||
case "noop":
|
||||
mc.logger.WarnContext(ctx, "noop found in the aggregation data")
|
||||
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
|
||||
}
|
||||
aggregation = map[string]any{
|
||||
"metricName": aggregateAttr["key"],
|
||||
"temporality": queryData["temporality"],
|
||||
"timeAggregation": timeAgg,
|
||||
"spaceAggregation": spaceAgg,
|
||||
}
|
||||
if widgetType == "table" {
|
||||
aggregation["reduceTo"] = reduceTo
|
||||
} else if reduceTo, ok := queryData["reduceTo"].(string); ok {
|
||||
aggregation["reduceTo"] = reduceTo
|
||||
}
|
||||
}
|
||||
case "logs", "traces":
|
||||
aggregation = map[string]any{"expression": mc.buildAggregationExpression(aggregateOp, aggregateAttr)}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
queryData["aggregations"] = []any{aggregation}
|
||||
return true
|
||||
}
|
||||
|
||||
// orderByIsPreV5 reports whether an orderBy slice is still in the v4 shape (an
|
||||
// entry carries "columnName"); a v5 orderBy uses {key:{name}, direction}.
|
||||
func orderByIsPreV5(orderBy []any) bool {
|
||||
for _, o := range orderBy {
|
||||
if m, ok := o.(map[string]any); ok {
|
||||
if _, has := m["columnName"]; has {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// functionsArePreV5 reports whether a functions slice is still in the v4 shape
|
||||
// (args are raw values); a v5 function's args are {name,value} objects.
|
||||
func functionsArePreV5(functions []any) bool {
|
||||
for _, f := range functions {
|
||||
if m, ok := f.(map[string]any); ok {
|
||||
args, ok := m["args"].([]any)
|
||||
if !ok || len(args) == 0 {
|
||||
continue
|
||||
}
|
||||
_, argIsObject := args[0].(map[string]any)
|
||||
return !argIsObject
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -125,10 +125,10 @@ type CollectedMetric struct {
|
||||
// This is used to show available pre-made dashboards for a service,
|
||||
// hence has additional fields like id, title and description.
|
||||
type Dashboard struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Definition dashboardtypes.PostableDashboardV2 `json:"definition"`
|
||||
}
|
||||
|
||||
type ServiceDashboard struct {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -22,6 +21,7 @@ var (
|
||||
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
|
||||
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
|
||||
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
|
||||
ErrCodeDashboardMigrationFailed = errors.MustNewCode("dashboard_migration_failed")
|
||||
)
|
||||
|
||||
type StorableDashboard struct {
|
||||
@@ -413,27 +413,26 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
|
||||
widgetData := data.Widgets[widgetIndex]
|
||||
switch widgetData.Query.QueryType {
|
||||
case "builder":
|
||||
migrate := transition.NewMigrateCommon(logger)
|
||||
for _, query := range widgetData.Query.Builder.QueryData {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
|
||||
}
|
||||
for _, query := range widgetData.Query.Builder.QueryFormulas {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
|
||||
}
|
||||
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
|
||||
}
|
||||
case "clickhouse_sql":
|
||||
for _, query := range widgetData.Query.ClickhouseSQL {
|
||||
|
||||
@@ -106,7 +106,7 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
}
|
||||
panelKind := panel.Spec.Plugin.Kind
|
||||
if len(panel.Spec.Queries) != 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query, found %d", path, len(panel.Spec.Queries))
|
||||
}
|
||||
allowed := allowedQueryKinds[panelKind]
|
||||
for qi, q := range panel.Spec.Queries {
|
||||
@@ -269,8 +269,8 @@ func (d *DashboardSpec) validateLayouts() error {
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
|
||||
}
|
||||
if grid.Display != nil {
|
||||
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxDisplayNameLen {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxDisplayNameLen, n)
|
||||
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxLayoutTitleLen {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxLayoutTitleLen, n)
|
||||
}
|
||||
}
|
||||
if err := validateGridLayoutGeometry(grid, li); err != nil {
|
||||
|
||||
@@ -1659,55 +1659,61 @@ func TestInvalidateDuplicatePanelReference(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
|
||||
}
|
||||
|
||||
// Every display name — dashboard, panel, variable — and the grid layout title is
|
||||
// bounded at MaxDisplayNameLen. The name is one over the limit in each case, and
|
||||
// the message reads "<json path>: <field> name must be at most ...", pairing the
|
||||
// locatable path (like the other spec errors) with a human field label.
|
||||
// Every display name — dashboard, panel, variable — is bounded at MaxDisplayNameLen,
|
||||
// while the grid layout title has its own, larger bound (MaxLayoutTitleLen). The name
|
||||
// is one over the relevant limit in each case, and the message reads "<json path>:
|
||||
// <field> name must be at most ...", pairing the locatable path (like the other spec
|
||||
// errors) with a human field label.
|
||||
func TestInvalidateDisplayNameTooLong(t *testing.T) {
|
||||
tooLong := strings.Repeat("x", MaxDisplayNameLen+1)
|
||||
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", MaxDisplayNameLen, MaxDisplayNameLen+1)
|
||||
|
||||
testCases := []struct {
|
||||
scenario string
|
||||
dashboardJSON string
|
||||
expectedPath string
|
||||
expectedLabel string
|
||||
scenario string
|
||||
limit int
|
||||
dashboardJSONFmt string
|
||||
expectedPath string
|
||||
expectedLabel string
|
||||
}{
|
||||
{
|
||||
scenario: "dashboard display name",
|
||||
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "layouts": []}`,
|
||||
expectedLabel: "dashboard",
|
||||
expectedPath: "spec.display.name",
|
||||
scenario: "dashboard display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"display": {"name": "%s"}, "layouts": []}`,
|
||||
expectedLabel: "dashboard",
|
||||
expectedPath: "spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "panel display name",
|
||||
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
|
||||
expectedLabel: "panel",
|
||||
expectedPath: "spec.panels.p1.spec.display.name",
|
||||
scenario: "panel display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
|
||||
expectedLabel: "panel",
|
||||
expectedPath: "spec.panels.p1.spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "list variable display name",
|
||||
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
scenario: "list variable display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "text variable display name",
|
||||
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
scenario: "text variable display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "layout title",
|
||||
dashboardJSON: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
|
||||
expectedLabel: "layout",
|
||||
expectedPath: "spec.layouts[0].spec.display.title",
|
||||
scenario: "layout title",
|
||||
limit: MaxLayoutTitleLen,
|
||||
dashboardJSONFmt: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
|
||||
expectedLabel: "layout",
|
||||
expectedPath: "spec.layouts[0].spec.display.title",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.scenario, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(testCase.dashboardJSON))
|
||||
tooLong := strings.Repeat("x", testCase.limit+1)
|
||||
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", testCase.limit, testCase.limit+1)
|
||||
_, err := unmarshalDashboard(fmt.Appendf(nil, testCase.dashboardJSONFmt, tooLong))
|
||||
require.Error(t, err)
|
||||
// Message is "<path>: <label> name must be at most N characters, got M".
|
||||
want := testCase.expectedPath + ": " + testCase.expectedLabel + " name " + lengthMsg
|
||||
|
||||
@@ -16,10 +16,14 @@ import (
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
// MaxDisplayNameLen bounds every human-readable display name — dashboard, panel,
|
||||
// and variable display names, plus the grid layout title.
|
||||
// MaxDisplayNameLen bounds the human-readable display names — dashboard, panel,
|
||||
// and variable. The grid layout title has its own, larger bound (MaxLayoutTitleLen).
|
||||
const MaxDisplayNameLen = 128
|
||||
|
||||
// MaxLayoutTitleLen bounds a grid layout title. It is larger than MaxDisplayNameLen
|
||||
// because v1 section (row) titles ran longer.
|
||||
const MaxLayoutTitleLen = 256
|
||||
|
||||
type Display struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
// Description always serializes ("" included) so a create -> GET round-trip
|
||||
|
||||
95
pkg/types/dashboardtypes/perses_v1_to_v2.go
Normal file
95
pkg/types/dashboardtypes/perses_v1_to_v2.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// V1 → V2 migration. The v1 storable shape is the frontend's `DashboardData`
|
||||
// (see frontend/src/types/api/dashboard/getAll.ts); v2 is DashboardV2 /
|
||||
// DashboardSpec.
|
||||
//
|
||||
// Assumes the v1 widget query data has already been migrated to v5 shape
|
||||
// (transition.dashboardMigrateV5). Pre-v5 builder queries will produce
|
||||
// invalid v2 envelopes — run the v4→v5 migration first.
|
||||
//
|
||||
// The conversion is split across sibling files by concern:
|
||||
// - perses_v1_to_v2_tags.go tags
|
||||
// - perses_v1_to_v2_panels.go widgets → panels (+ panel field mappers)
|
||||
// - perses_v1_to_v2_queries.go widget queries
|
||||
// - perses_v1_to_v2_layouts.go grid layouts and sections
|
||||
// - perses_v1_to_v2_variables.go variables
|
||||
// - perses_v1_to_v2_decoder.go v1Decoder: typed field reads + malformed-field detection
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Entry point
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func (storable StorableDashboard) IsV2() bool {
|
||||
metadata, _ := storable.Data["metadata"].(map[string]any)
|
||||
if metadata == nil {
|
||||
return false
|
||||
}
|
||||
version, _ := metadata["schemaVersion"].(string)
|
||||
return version == SchemaVersion
|
||||
}
|
||||
|
||||
func (storable StorableDashboard) ConvertV1ToV2() (result *DashboardV2, err error) {
|
||||
// Legacy v1 data can be arbitrarily malformed. The accessors degrade
|
||||
// gracefully, but recover from any unforeseen panic so one bad dashboard
|
||||
// surfaces as an error (to be logged and skipped) rather than crashing the run.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
result, err = nil, errors.Newf(errors.TypeInternal, ErrCodeDashboardMigrationFailed, "panic converting dashboard %s: %v", storable.ID, r)
|
||||
}
|
||||
}()
|
||||
|
||||
if storable.IsV2() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardMigrationFailed, "dashboard %s is already in %s schema", storable.ID, SchemaVersion)
|
||||
}
|
||||
|
||||
d := &v1Decoder{}
|
||||
title := d.readString(storable.Data, "title")
|
||||
description := d.readString(storable.Data, "description")
|
||||
image := d.readString(storable.Data, "image")
|
||||
|
||||
sanitizeWidgetIDs(storable.Data)
|
||||
panels := d.convertV1Panels(retainPlacedWidgets(storable.Data))
|
||||
spec := DashboardSpec{
|
||||
Display: Display{Name: clipName(title, MaxDisplayNameLen), Description: description},
|
||||
Variables: d.convertV1Variables(storable.Data["variables"]),
|
||||
Panels: panels,
|
||||
Layouts: d.convertV1Layouts(storable.Data, panels),
|
||||
}
|
||||
|
||||
// marshal and unmarshal cycle to confirm full validation
|
||||
raw, marshalErr := json.Marshal(spec)
|
||||
if marshalErr != nil {
|
||||
return nil, errors.WrapInternalf(marshalErr, errors.CodeInternal, "marshal converted dashboard %s", storable.ID)
|
||||
}
|
||||
if err := json.Unmarshal(raw, new(DashboardSpec)); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardMigrationFailed, "converted dashboard %s is invalid", storable.ID)
|
||||
}
|
||||
tags := d.convertV1TagsForOrg(storable.OrgID, storable.Data["tags"])
|
||||
|
||||
if err := d.errIfHasMalformedFields(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DashboardV2{
|
||||
Identifiable: storable.Identifiable,
|
||||
TimeAuditable: storable.TimeAuditable,
|
||||
UserAuditable: storable.UserAuditable,
|
||||
OrgID: storable.OrgID,
|
||||
Locked: storable.Locked,
|
||||
Source: storable.Source,
|
||||
DashboardV2MetadataBase: DashboardV2MetadataBase{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Image: image,
|
||||
},
|
||||
Name: generateDashboardName(title),
|
||||
Tags: tags,
|
||||
Spec: spec,
|
||||
}, nil
|
||||
}
|
||||
214
pkg/types/dashboardtypes/perses_v1_to_v2_decoder.go
Normal file
214
pkg/types/dashboardtypes/perses_v1_to_v2_decoder.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// v1 decoder
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// v1Decoder reads fields out of the untyped v1 dashboard blob. Every read*
|
||||
// method follows the same contract: a field that is absent or null yields the
|
||||
// zero value; a field present with the wrong type yields zero AND records a
|
||||
// malformed-field error. Conversion proceeds (so one bad field doesn't abort
|
||||
// the rest) and ConvertV1ToV2 returns d.malformedFieldsErr() at the end so the
|
||||
// dashboard is logged and skipped.
|
||||
//
|
||||
// Polymorphic v1 fields (spanGaps bool|number, selectedValue string|array, …)
|
||||
// are read with a type switch on the already-extracted value, never through
|
||||
// these accessors, so they stay lenient by construction.
|
||||
type v1Decoder struct {
|
||||
bad []string
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
// note records a decoding problem (malformed field, unknown value, swallowed
|
||||
// sub-parse error), deduping identical messages. ConvertV1ToV2 surfaces these
|
||||
// via errIfHasMalformedFields.
|
||||
func (d *v1Decoder) note(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if _, dup := d.seen[msg]; dup {
|
||||
return
|
||||
}
|
||||
if d.seen == nil {
|
||||
d.seen = make(map[string]struct{})
|
||||
}
|
||||
d.seen[msg] = struct{}{}
|
||||
d.bad = append(d.bad, msg)
|
||||
}
|
||||
|
||||
// noteMalformedField records a v1 field present with the wrong Go type.
|
||||
func (d *v1Decoder) noteMalformedField(field string, raw any) {
|
||||
d.note("%q has unexpected type %T", field, raw)
|
||||
}
|
||||
|
||||
// detailErr renders an error for a diagnostic note, unfolding the structured
|
||||
// detail our JSON binding attaches via WithAdditional. A plain %v on these
|
||||
// errors prints only the innermost message ("request body contains invalid
|
||||
// field value") and drops the field/type context that says which field was
|
||||
// wrong — the part that actually tells you what to fix.
|
||||
func detailErr(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
j := errors.AsJSON(err)
|
||||
if len(j.Errors) == 0 {
|
||||
return err.Error()
|
||||
}
|
||||
details := make([]string, 0, len(j.Errors))
|
||||
for _, e := range j.Errors {
|
||||
details = append(details, e.Message)
|
||||
}
|
||||
return j.Message + ": " + strings.Join(details, "; ")
|
||||
}
|
||||
|
||||
func (d *v1Decoder) errIfHasMalformedFields() error {
|
||||
if len(d.bad) == 0 {
|
||||
return nil
|
||||
}
|
||||
// One field per line: these lists run long (a bad widget query is reported
|
||||
// once per widget), and a single "; "-joined line is an unscannable wall.
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidData, "malformed v1 dashboard fields:\n %s", strings.Join(d.bad, "\n "))
|
||||
}
|
||||
|
||||
func readField[T any](d *v1Decoder, m map[string]any, key string) T {
|
||||
var zero T
|
||||
v, present := m[key]
|
||||
if !present || v == nil {
|
||||
return zero
|
||||
}
|
||||
t, ok := v.(T)
|
||||
if !ok {
|
||||
d.noteMalformedField(key, v)
|
||||
return zero
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (d *v1Decoder) readString(m map[string]any, key string) string {
|
||||
return readField[string](d, m, key)
|
||||
}
|
||||
func (d *v1Decoder) readFloat(m map[string]any, key string) float64 {
|
||||
v, present := m[key]
|
||||
if !present || v == nil {
|
||||
return 0
|
||||
}
|
||||
f, ok := coerceFloat(v)
|
||||
if !ok {
|
||||
d.noteMalformedField(key, v)
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// coerceFloat accepts a JSON number or a numeric string (v1 sometimes stores
|
||||
// numbers like softMin as quoted strings). A blank string is "unset", not a
|
||||
// number, so it fails to coerce.
|
||||
func coerceFloat(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
func (d *v1Decoder) readBool(m map[string]any, key string) bool { return readField[bool](d, m, key) }
|
||||
func (d *v1Decoder) readArray(m map[string]any, key string) []any { return readField[[]any](d, m, key) }
|
||||
func (d *v1Decoder) readObject(m map[string]any, key string) map[string]any {
|
||||
return readField[map[string]any](d, m, key)
|
||||
}
|
||||
|
||||
// readInt narrows a numeric field to int (JSON numbers decode as float64).
|
||||
func (d *v1Decoder) readInt(m map[string]any, key string) int { return int(d.readFloat(m, key)) }
|
||||
|
||||
func (d *v1Decoder) readFloatPtr(m map[string]any, key string) *float64 {
|
||||
v, present := m[key]
|
||||
if !present || v == nil {
|
||||
return nil
|
||||
}
|
||||
// A blank string means "unset" (v1's empty softMin/softMax), not malformed.
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
f, ok := coerceFloat(v)
|
||||
if !ok {
|
||||
d.noteMalformedField(key, v)
|
||||
return nil
|
||||
}
|
||||
return &f
|
||||
}
|
||||
|
||||
// clipName truncates s to at most limit runes so a v1 name over a v2 length bound
|
||||
// (MaxDisplayNameLen / MaxLayoutTitleLen) is shortened rather than failing migration.
|
||||
func clipName(s string, limit int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= limit {
|
||||
return s
|
||||
}
|
||||
return string(r[:limit])
|
||||
}
|
||||
|
||||
func (d *v1Decoder) readStringMap(m map[string]any, key string) map[string]string {
|
||||
// An empty list is a stand-in for an empty map here; tolerate it silently
|
||||
// rather than flagging the wrong-type as malformed.
|
||||
if s, ok := m[key].([]any); ok && len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw := d.readObject(m, key)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(raw))
|
||||
for k, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
d.noteMalformedField(key+"."+k, v)
|
||||
continue
|
||||
}
|
||||
out[k] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *v1Decoder) readObjects(m map[string]any, key string) []map[string]any {
|
||||
raw := d.readArray(m, key)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for i, item := range raw {
|
||||
obj, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
d.noteMalformedField(fmt.Sprintf("%s[%d]", key, i), item)
|
||||
continue
|
||||
}
|
||||
out = append(out, obj)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decodeMapInto converts an untyped map[string]any into a typed T by
|
||||
// round-tripping through JSON, letting encoding/json (struct tags, custom
|
||||
// UnmarshalJSON) do the field mapping instead of hand-copying out of the map.
|
||||
func decodeMapInto[T any](src map[string]any) (T, error) {
|
||||
var dst T
|
||||
bytes, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
return dst, err
|
||||
}
|
||||
if err := json.Unmarshal(bytes, &dst); err != nil {
|
||||
return dst, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
349
pkg/types/dashboardtypes/perses_v1_to_v2_layouts.go
Normal file
349
pkg/types/dashboardtypes/perses_v1_to_v2_layouts.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/perses/spec/go/common"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
)
|
||||
|
||||
// panelRefPrefix is the JSON-ref prefix a grid item uses to point at a panel:
|
||||
// "#/spec/panels/<id>".
|
||||
const panelRefPrefix = "#/spec/panels/"
|
||||
|
||||
// sanitizePanelID rewrites a widget id to something valid in a panel $ref. Perses
|
||||
// accepts only [a-zA-Z0-9_-] per ref segment (common.jsonRefMatching), so every
|
||||
// other rune (em dash, spaces, dots, unicode, …) is mapped to a hyphen.
|
||||
func sanitizePanelID(id string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||
return r
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}, id)
|
||||
}
|
||||
|
||||
// sanitizeWidgetIDs rewrites every widget id in the raw v1 data — widgets[].id,
|
||||
// layout[].i, panelMap keys and their widgets[].i — through sanitizePanelID, so a
|
||||
// panel's map key and the layout $ref pointing at it stay identical (an illegal char
|
||||
// in one but not the other would dangle the ref). Runs before panels/layouts build.
|
||||
func sanitizeWidgetIDs(data StorableDashboardData) {
|
||||
sanitizeField := func(raw any, field string) {
|
||||
items, _ := raw.([]any)
|
||||
for _, it := range items {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
if s, ok := m[field].(string); ok {
|
||||
m[field] = sanitizePanelID(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sanitizeField(data["widgets"], "id")
|
||||
sanitizeField(data["layout"], "i")
|
||||
if panelMap, ok := data["panelMap"].(map[string]any); ok {
|
||||
for key, v := range panelMap {
|
||||
if s := sanitizePanelID(key); s != key {
|
||||
panelMap[s] = v
|
||||
delete(panelMap, key)
|
||||
}
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
sanitizeField(m["widgets"], "i")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Layouts (data.layout + data.panelMap)
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Layouts groups v1 react-grid-layout entries into v2 grid layouts.
|
||||
// Membership is positional (as the frontend renders): each row widget owns the
|
||||
// panels below it until the next row; panels above the first row form an unnamed
|
||||
// grid with no section header. Collapsed rows are the exception — their children
|
||||
// live in panelMap[rowID].widgets, not `layout`.
|
||||
func (d *v1Decoder) convertV1Layouts(data StorableDashboardData, panels map[string]*Panel) []Layout {
|
||||
layout := d.readObjects(data, "layout")
|
||||
if len(layout) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// react-grid-layout can persist the same widget id more than once. Keep the first
|
||||
// occurrence in stored order (mirroring getUpdatedLayout — the losing entry's
|
||||
// geometry is discarded, not merged) and drop the rest. Dedupe before sortByPosition
|
||||
// so "first" means first-in-stored-order, not topmost. Entries with no id are left
|
||||
// for the main loop to drop.
|
||||
seenWidgetIds := make(map[string]bool, len(layout))
|
||||
dedupedLayouts := layout[:0]
|
||||
for _, item := range layout {
|
||||
if id := d.readString(item, "i"); id != "" {
|
||||
if seenWidgetIds[id] {
|
||||
continue
|
||||
}
|
||||
seenWidgetIds[id] = true
|
||||
}
|
||||
dedupedLayouts = append(dedupedLayouts, item)
|
||||
}
|
||||
layout = dedupedLayouts
|
||||
|
||||
rows := d.extractRowsAndCollapsedWidgets(data)
|
||||
|
||||
// ids placed directly in `layout`. A collapsed child also listed here is rendered from
|
||||
// layout (the open section), so it's dropped from its collapsed section below.
|
||||
placedInLayout := make(map[string]bool, len(layout))
|
||||
for _, item := range layout {
|
||||
if id := d.readString(item, "i"); id != "" {
|
||||
placedInLayout[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
d.sortByPosition(layout)
|
||||
|
||||
type section struct {
|
||||
row *rowInfo // nil for the unnamed grid of ungrouped panels
|
||||
items []map[string]any
|
||||
}
|
||||
topSectionWithoutHeader := §ion{}
|
||||
sectionsWithHeader := make([]*section, 0, len(rows))
|
||||
currentRowHeader := topSectionWithoutHeader
|
||||
for _, item := range layout {
|
||||
id := d.readString(item, "i")
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if row, ok := rows[id]; ok {
|
||||
newRowHeader := §ion{row: row, items: d.extractValidLayoutItemsForCollapsedSection(row.collapsedWidgets, panels, placedInLayout)}
|
||||
sectionsWithHeader = append(sectionsWithHeader, newRowHeader)
|
||||
// A collapsed row owns only its stashed children; later panels → ungrouped.
|
||||
if row.collapsed {
|
||||
currentRowHeader = topSectionWithoutHeader
|
||||
} else {
|
||||
currentRowHeader = newRowHeader
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Keep a layout entry only if its widget became a panel; otherwise (skipped
|
||||
// widget, deleted id, or the "__dropping-elem__" drag placeholder) it would
|
||||
// reference a panel that does not exist. Rows are handled above.
|
||||
if _, ok := panels[id]; !ok {
|
||||
continue
|
||||
}
|
||||
currentRowHeader.items = append(currentRowHeader.items, item)
|
||||
}
|
||||
|
||||
out := make([]Layout, 0, len(sectionsWithHeader)+1)
|
||||
if len(topSectionWithoutHeader.items) > 0 {
|
||||
out = append(out, d.buildV2GridLayout(nil, topSectionWithoutHeader.items))
|
||||
}
|
||||
for _, sec := range sectionsWithHeader {
|
||||
out = append(out, d.buildV2GridLayout(sec.row, sec.items))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// retainPlacedWidgets drops widgets the v1 layout never places, returning the
|
||||
// filtered widgets. v1 doesn't render an unplaced widget, so converting it — and
|
||||
// noting any problems it has — is pure noise; filter before conversion so only
|
||||
// rendered widgets reach convertV1Panels. A non-array widgets value is returned
|
||||
// untouched for convertV1Panels to flag; non-map entries are kept so it still
|
||||
// flags them as malformed.
|
||||
func retainPlacedWidgets(data StorableDashboardData) any {
|
||||
widgets, ok := data["widgets"].([]any)
|
||||
if !ok {
|
||||
return data["widgets"]
|
||||
}
|
||||
placed := placedWidgetIDs(data)
|
||||
kept := make([]any, 0, len(widgets))
|
||||
for _, w := range widgets {
|
||||
wm, ok := w.(map[string]any)
|
||||
if !ok {
|
||||
kept = append(kept, w) // malformed entry — leave it for convertV1Panels to note
|
||||
continue
|
||||
}
|
||||
if id, _ := wm["id"].(string); placed[id] {
|
||||
kept = append(kept, w)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// placedWidgetIDs returns the set of widget ids the v1 layout actually renders:
|
||||
// every id in `layout`, plus the collapsed-row children stashed in panelMap.
|
||||
// Read leniently (no malformed notes) — convertV1Layouts re-reads these and
|
||||
// reports any genuine problems.
|
||||
func placedWidgetIDs(data StorableDashboardData) map[string]bool {
|
||||
ids := make(map[string]bool)
|
||||
if layout, ok := data["layout"].([]any); ok {
|
||||
for _, e := range layout {
|
||||
if m, ok := e.(map[string]any); ok {
|
||||
if i, ok := m["i"].(string); ok && i != "" {
|
||||
ids[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if panelMap, ok := data["panelMap"].(map[string]any); ok {
|
||||
for _, v := range panelMap {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
widgets, ok := m["widgets"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, w := range widgets {
|
||||
if wm, ok := w.(map[string]any); ok {
|
||||
if i, ok := wm["i"].(string); ok && i != "" {
|
||||
ids[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// extractValidLayoutItemsForCollapsedSection keeps only the collapsed-row children
|
||||
// backed by a real panel and not already placed in `layout`, dropping ghosts and any
|
||||
// child the open layout renders instead. These come from panelMap and skip the main
|
||||
// loop's per-item panel check, so a grid never references a missing or twice-placed panel.
|
||||
func (d *v1Decoder) extractValidLayoutItemsForCollapsedSection(items []map[string]any, panels map[string]*Panel, placedInLayout map[string]bool) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
id := d.readString(item, "i")
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := panels[id]; !ok {
|
||||
continue
|
||||
}
|
||||
if placedInLayout[id] || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type rowInfo struct {
|
||||
title string
|
||||
collapsed bool
|
||||
collapsedWidgets []map[string]any
|
||||
}
|
||||
|
||||
// extractRowsAndCollapsedWidgets returns the row widgets keyed by id; collapsed
|
||||
// rows also carry their children stashed under panelMap[id].widgets.
|
||||
func (d *v1Decoder) extractRowsAndCollapsedWidgets(data StorableDashboardData) map[string]*rowInfo {
|
||||
panelMap := d.readObject(data, "panelMap")
|
||||
rows := make(map[string]*rowInfo)
|
||||
for _, w := range d.readObjects(data, "widgets") {
|
||||
// Read id directly (not via readString): a non-string id is skipped silently by
|
||||
// convertV1Panels, so flagging it malformed here would fail the migration for a
|
||||
// widget that's already been dropped.
|
||||
id, _ := w["id"].(string)
|
||||
if d.readString(w, "panelTypes") != "row" || id == "" {
|
||||
continue
|
||||
}
|
||||
row := &rowInfo{title: d.readString(w, "title")}
|
||||
// Some templates store panelMap[id] as a bare []widgetID instead of the
|
||||
// canonical {widgets, collapsed}. The frontend treats such a non-object
|
||||
// entry as "not collapsed" (see GridCardLayout), so read it leniently: a
|
||||
// non-map yields nil, which reads as not collapsed.
|
||||
pm, _ := panelMap[id].(map[string]any)
|
||||
if d.readBool(pm, "collapsed") {
|
||||
row.collapsed = true
|
||||
row.collapsedWidgets = d.readObjects(pm, "widgets")
|
||||
}
|
||||
rows[id] = row
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildV2GridLayout builds one v2 grid. row is nil for the unnamed grid (no
|
||||
// display); otherwise the grid takes the row's title and collapse state. Items are
|
||||
// sorted by (y, x) then vertically compacted (see compactGridItemsVertically).
|
||||
func (d *v1Decoder) buildV2GridLayout(row *rowInfo, items []map[string]any) Layout {
|
||||
d.sortByPosition(items)
|
||||
|
||||
spec := dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, 0, len(items))}
|
||||
if row != nil {
|
||||
spec.Display = &dashboard.GridLayoutDisplay{
|
||||
Title: clipName(row.title, MaxLayoutTitleLen),
|
||||
Collapse: &dashboard.GridLayoutCollapse{Open: !row.collapsed},
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
spec.Items = append(spec.Items, dashboard.GridItem{
|
||||
X: d.readInt(item, "x"),
|
||||
Y: d.readInt(item, "y"),
|
||||
Width: d.readInt(item, "w"),
|
||||
Height: d.readInt(item, "h"),
|
||||
Content: &common.JSONRef{Ref: panelRefPrefix + d.readString(item, "i")},
|
||||
})
|
||||
}
|
||||
compactGridItemsVertically(spec.Items)
|
||||
return Layout{Kind: dashboard.KindGridLayout, Spec: &spec}
|
||||
}
|
||||
|
||||
// compactGridItemsVertically mirrors react-grid-layout's correctBounds+compact
|
||||
// (compactType "vertical", allowOverlap false): clamp each item into the grid (x,y>=0;
|
||||
// x+width<=cols by shifting left), then move sorted-first items up to fill space and
|
||||
// down past collisions. Fixes overlaps, gaps, and out-of-bounds coords so the migrated
|
||||
// grid matches the v1 UI and passes v2 validation.
|
||||
func compactGridItemsVertically(items []dashboard.GridItem) {
|
||||
collides := func(a, b dashboard.GridItem) bool {
|
||||
return a.X < b.X+b.Width && b.X < a.X+a.Width && a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
|
||||
}
|
||||
firstCollision := func(l dashboard.GridItem, placed []dashboard.GridItem) (dashboard.GridItem, bool) {
|
||||
for _, p := range placed {
|
||||
if collides(l, p) {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return dashboard.GridItem{}, false
|
||||
}
|
||||
for i := range items {
|
||||
l := items[i]
|
||||
if l.X+l.Width > gridColumnCount { // overflows right → shift left to fit
|
||||
l.X = gridColumnCount - l.Width
|
||||
}
|
||||
if l.X < 0 {
|
||||
l.X = 0
|
||||
}
|
||||
if l.Y < 0 {
|
||||
l.Y = 0
|
||||
}
|
||||
for l.Y > 0 { // move up to fill space above
|
||||
up := l
|
||||
up.Y--
|
||||
if _, hit := firstCollision(up, items[:i]); hit {
|
||||
break
|
||||
}
|
||||
l.Y--
|
||||
}
|
||||
for { // then down past any collision with an already-placed item
|
||||
c, hit := firstCollision(l, items[:i])
|
||||
if !hit {
|
||||
break
|
||||
}
|
||||
l.Y = c.Y + c.Height
|
||||
}
|
||||
items[i] = l
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) sortByPosition(items []map[string]any) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if yi, yj := d.readInt(items[i], "y"), d.readInt(items[j], "y"); yi != yj {
|
||||
return yi < yj
|
||||
}
|
||||
return d.readInt(items[i], "x") < d.readInt(items[j], "x")
|
||||
})
|
||||
}
|
||||
484
pkg/types/dashboardtypes/perses_v1_to_v2_panels.go
Normal file
484
pkg/types/dashboardtypes/perses_v1_to_v2_panels.go
Normal file
@@ -0,0 +1,484 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Widgets → Panels
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Panels walks the v1 `widgets` array and produces v2 panels keyed by
|
||||
// the v1 widget id. WidgetRow entries (panelTypes == "row") are dropped here
|
||||
// and consumed by convertV1Layouts as section headers.
|
||||
func (d *v1Decoder) convertV1Panels(raw any) map[string]*Panel {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
widgetsRaw, ok := raw.([]any)
|
||||
if !ok {
|
||||
d.noteMalformedField("widgets", raw)
|
||||
return nil
|
||||
}
|
||||
panels := make(map[string]*Panel, len(widgetsRaw))
|
||||
for i, widgetRaw := range widgetsRaw {
|
||||
widget, ok := widgetRaw.(map[string]any)
|
||||
if !ok {
|
||||
d.noteMalformedField(fmt.Sprintf("widgets[%d]", i), widgetRaw)
|
||||
continue
|
||||
}
|
||||
// A non-string (or missing) id can't be referenced by any layout entry, and
|
||||
// v1 doesn't render such widgets either — skip silently, don't flag it as
|
||||
// malformed. Read directly (not via readString) to avoid a malformed note.
|
||||
id, ok := widget["id"].(string)
|
||||
if !ok || id == "" {
|
||||
continue
|
||||
}
|
||||
var panel *Panel
|
||||
panelType := d.readString(widget, "panelTypes")
|
||||
switch panelType {
|
||||
case "graph":
|
||||
panel = d.convertGraphWidget(widget)
|
||||
case "time_series", "TIME_SERIES":
|
||||
// Malformed panelTypes: the canonical v1 value is "graph". Some dashboards
|
||||
// stored the v2/enum-style name instead; accept it as a time-series graph.
|
||||
panel = d.convertGraphWidget(widget)
|
||||
case "bar":
|
||||
panel = d.convertBarWidget(widget)
|
||||
case "value":
|
||||
panel = d.convertValueWidget(widget)
|
||||
case "pie":
|
||||
panel = d.convertPieWidget(widget)
|
||||
case "table":
|
||||
panel = d.convertTableWidget(widget)
|
||||
case "histogram":
|
||||
panel = d.convertHistogramWidget(widget)
|
||||
case "list":
|
||||
panel = d.convertListWidget(widget)
|
||||
case "row":
|
||||
// "row" (section header) is handled by the layout pass;
|
||||
continue
|
||||
default:
|
||||
// Unknown/unsupported panel type — v1 can't render it either, so skip the
|
||||
// widget silently rather than failing the whole migration.
|
||||
continue
|
||||
}
|
||||
if panel == nil {
|
||||
continue
|
||||
}
|
||||
if len(panel.Spec.Queries) == 0 {
|
||||
// No renderable queries — every query was dropped as unrenderable, or none
|
||||
// were defined. v1 renders nothing, so skip the widget silently.
|
||||
continue
|
||||
}
|
||||
panels[id] = panel
|
||||
}
|
||||
return panels
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertGraphWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindTimeSeries,
|
||||
Spec: &TimeSeriesPanelSpec{
|
||||
Visualization: TimeSeriesVisualization{
|
||||
BasicVisualization: d.basicVisualization(w),
|
||||
FillSpans: d.readBool(w, "fillSpans"),
|
||||
},
|
||||
Formatting: d.panelFormatting(w),
|
||||
ChartAppearance: TimeSeriesChartAppearance{
|
||||
LineInterpolation: mapV1Enum(d.readString(w, "lineInterpolation"), LineInterpolationSpline,
|
||||
LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore),
|
||||
ShowPoints: d.readBool(w, "showPoints"),
|
||||
LineStyle: mapV1Enum(d.readString(w, "lineStyle"), LineStyleSolid, LineStyleSolid, LineStyleDashed),
|
||||
FillMode: mapV1Enum(d.readString(w, "fillMode"), FillModeNone, FillModeSolid, FillModeGradient, FillModeNone),
|
||||
SpanGaps: mapV1SpanGaps(w["spanGaps"]),
|
||||
},
|
||||
Axes: d.axesFromWidget(w),
|
||||
Legend: d.legendFromWidget(w),
|
||||
Thresholds: d.mapV1ThresholdsWithLabel(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindTimeSeries),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertBarWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindBarChart,
|
||||
Spec: &BarChartPanelSpec{
|
||||
Visualization: BarChartVisualization{
|
||||
BasicVisualization: d.basicVisualization(w),
|
||||
FillSpans: d.readBool(w, "fillSpans"),
|
||||
StackedBarChart: d.readBool(w, "stackedBarChart"),
|
||||
},
|
||||
Formatting: d.panelFormatting(w),
|
||||
Axes: d.axesFromWidget(w),
|
||||
Legend: d.legendFromWidget(w),
|
||||
Thresholds: d.mapV1ThresholdsWithLabel(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindBarChart),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertValueWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindNumber,
|
||||
Spec: &NumberPanelSpec{
|
||||
Visualization: d.basicVisualization(w),
|
||||
Formatting: d.panelFormatting(w),
|
||||
Thresholds: d.mapV1ComparisonThresholds(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindNumber),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertPieWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindPieChart,
|
||||
Spec: &PieChartPanelSpec{
|
||||
Visualization: d.basicVisualization(w),
|
||||
Formatting: d.panelFormatting(w),
|
||||
Legend: d.legendFromWidget(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindPieChart),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertTableWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindTable,
|
||||
Spec: &TablePanelSpec{
|
||||
Visualization: d.basicVisualization(w),
|
||||
Formatting: TableFormatting{
|
||||
ColumnUnits: d.readStringMap(w, "columnUnits"),
|
||||
DecimalPrecision: mapV1Precision(w["decimalPrecision"]),
|
||||
},
|
||||
Thresholds: d.mapV1TableThresholds(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindTable),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertHistogramWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindHistogram,
|
||||
Spec: &HistogramPanelSpec{
|
||||
HistogramBuckets: HistogramBuckets{
|
||||
BucketCount: d.readFloatPtr(w, "bucketCount"),
|
||||
BucketWidth: d.readFloatPtr(w, "bucketWidth"),
|
||||
MergeAllActiveQueries: d.readBool(w, "mergeAllActiveQueries"),
|
||||
},
|
||||
Legend: d.legendFromWidget(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindHistogram),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertListWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: d.widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindList,
|
||||
Spec: &ListPanelSpec{
|
||||
SelectFields: d.mapV1SelectFields(w),
|
||||
},
|
||||
},
|
||||
Queries: d.convertV1WidgetQuery(w, PanelKindList),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel-spec shared helpers
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func (d *v1Decoder) widgetDisplay(w map[string]any) Display {
|
||||
return Display{Name: clipName(d.readString(w, "title"), MaxDisplayNameLen), Description: d.readString(w, "description")}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) basicVisualization(w map[string]any) BasicVisualization {
|
||||
return BasicVisualization{TimePreference: mapV1TimePreference(d.readString(w, "timePreferance"))}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) panelFormatting(w map[string]any) PanelFormatting {
|
||||
return PanelFormatting{Unit: d.readString(w, "yAxisUnit"), DecimalPrecision: mapV1Precision(w["decimalPrecision"])}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) axesFromWidget(w map[string]any) Axes {
|
||||
return Axes{
|
||||
SoftMin: d.readFloatPtr(w, "softMin"),
|
||||
SoftMax: d.readFloatPtr(w, "softMax"),
|
||||
IsLogScale: d.readBool(w, "isLogScale"),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) legendFromWidget(w map[string]any) Legend {
|
||||
return Legend{
|
||||
Position: mapV1Enum(d.readString(w, "legendPosition"), LegendPositionBottom, LegendPositionBottom, LegendPositionRight),
|
||||
CustomColors: d.readStringMap(w, "customLegendColors"),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) mapV1SelectFields(w map[string]any) []telemetrytypes.TelemetryFieldKey {
|
||||
field := "selectedLogFields"
|
||||
raw := d.readArray(w, field)
|
||||
if len(raw) == 0 {
|
||||
field = "selectedTracesFields"
|
||||
raw = d.readArray(w, field)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
normalizePreV5FieldKeys(raw)
|
||||
fields, err := decodeTelemetryFields(raw)
|
||||
if err != nil {
|
||||
d.note("widget %q has malformed %s: %v", d.readString(w, "id"), field, err)
|
||||
return nil
|
||||
}
|
||||
// Drop nameless entries (blank column rows) — v2 requires a name, and the v1
|
||||
// UI renders nothing for them anyway.
|
||||
out := fields[:0]
|
||||
for _, f := range fields {
|
||||
if f.Name != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeTelemetryFields(raw []any) ([]telemetrytypes.TelemetryFieldKey, error) {
|
||||
bytes, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fields []telemetrytypes.TelemetryFieldKey
|
||||
if err := json.Unmarshal(bytes, &fields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel field mappers
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// v1 stores timePreferance as `GLOBAL_TIME`, `LAST_5_MIN`, … (see
|
||||
// frontend/src/container/NewWidget/RightContainer/timeItems.ts). v2 uses the
|
||||
// lowercase form, so the translation is just downcase.
|
||||
func mapV1TimePreference(s string) TimePreference {
|
||||
if s == "" {
|
||||
return TimePreferenceGlobalTime
|
||||
}
|
||||
candidate := TimePreference{valuer.NewString(strings.ToLower(s))}
|
||||
for _, allowed := range candidate.Enum() {
|
||||
if allowed == candidate {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return TimePreferenceGlobalTime
|
||||
}
|
||||
|
||||
// mapV1Precision is polymorphic (string|number), so it type-switches the raw
|
||||
// value rather than reading through a typed accessor.
|
||||
func mapV1Precision(raw any) PrecisionOption {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
candidate := PrecisionOption{valuer.NewString(v)}
|
||||
for _, allowed := range candidate.Enum() {
|
||||
if allowed == candidate {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
case float64:
|
||||
n := int(v)
|
||||
if n >= 0 && n <= 4 {
|
||||
return PrecisionOption{valuer.NewString(strconv.Itoa(n))}
|
||||
}
|
||||
}
|
||||
return PrecisionOption2
|
||||
}
|
||||
|
||||
// mapV1Enum picks the v1 string value if it matches one of the allowed v2
|
||||
// values, otherwise returns the fallback. v1 frontend enums (lineInterpolation,
|
||||
// lineStyle, fillMode, legendPosition) already use the v2 lowercase form.
|
||||
func mapV1Enum[T interface{ StringValue() string }](s string, fallback T, allowed ...T) T {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if a.StringValue() == s {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// v1 spanGaps is `boolean | number`. true → span every gap; false → never span;
|
||||
// a number is interpreted (per frontend SeriesProps.spanGaps docs) as an
|
||||
// X-axis threshold in seconds. Polymorphic, so it type-switches the raw value.
|
||||
func mapV1SpanGaps(raw any) SpanGaps {
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
return SpanGaps{FillOnlyBelow: false}
|
||||
case float64:
|
||||
return SpanGaps{FillOnlyBelow: true, FillLessThan: time.Duration(v * float64(time.Second)).String()}
|
||||
}
|
||||
return SpanGaps{FillOnlyBelow: false}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) mapV1ThresholdsWithLabel(w map[string]any) []ThresholdWithLabel {
|
||||
rawSlice := d.readObjects(w, "thresholds")
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ThresholdWithLabel, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color := d.readString(t, "thresholdColor")
|
||||
label := d.readString(t, "thresholdLabel")
|
||||
if color == "" || label == "" {
|
||||
// v2 ThresholdWithLabel requires both; drop entries that wouldn't validate.
|
||||
continue
|
||||
}
|
||||
value := d.readFloat(t, "thresholdValue")
|
||||
out = append(out, ThresholdWithLabel{Value: value, Unit: d.readString(t, "thresholdUnit"), Color: color, Label: label})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *v1Decoder) mapV1ComparisonThresholds(w map[string]any) []ComparisonThreshold {
|
||||
rawSlice := d.readObjects(w, "thresholds")
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ComparisonThreshold, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color := d.readString(t, "thresholdColor")
|
||||
if color == "" {
|
||||
continue
|
||||
}
|
||||
value := d.readFloat(t, "thresholdValue")
|
||||
out = append(out, ComparisonThreshold{
|
||||
Value: value,
|
||||
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
|
||||
Unit: d.readString(t, "thresholdUnit"),
|
||||
Color: color,
|
||||
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *v1Decoder) mapV1TableThresholds(w map[string]any) []TableThreshold {
|
||||
rawSlice := d.readObjects(w, "thresholds")
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]TableThreshold, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color := d.readString(t, "thresholdColor")
|
||||
columnName := d.readString(t, "thresholdTableOptions")
|
||||
if color == "" || columnName == "" {
|
||||
continue
|
||||
}
|
||||
value := d.readFloat(t, "thresholdValue")
|
||||
out = append(out, TableThreshold{
|
||||
ComparisonThreshold: ComparisonThreshold{
|
||||
Value: value,
|
||||
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
|
||||
Unit: d.readString(t, "thresholdUnit"),
|
||||
Color: color,
|
||||
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
|
||||
},
|
||||
ColumnName: columnName,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d *v1Decoder) mapV1ComparisonOperator(s string) ComparisonOperator {
|
||||
switch s {
|
||||
case ">", "gt":
|
||||
return ComparisonOperatorAbove
|
||||
case ">=", "gte":
|
||||
return ComparisonOperatorAboveOrEqual
|
||||
case "<", "lt":
|
||||
return ComparisonOperatorBelow
|
||||
case "<=", "lte":
|
||||
return ComparisonOperatorBelowOrEqual
|
||||
case "=", "==", "eq":
|
||||
return ComparisonOperatorEqual
|
||||
case "!=", "neq":
|
||||
return ComparisonOperatorNotEqual
|
||||
default:
|
||||
// v1 often leaves the operator empty or carries an unknown value; default to
|
||||
// "above" without flagging.
|
||||
return ComparisonOperatorAbove
|
||||
}
|
||||
}
|
||||
|
||||
// mapV1ThresholdFormat reads the raw value (not via readString) so a non-string
|
||||
// thresholdFormat — some v1 dashboards store it as a number — defaults to text
|
||||
// silently instead of being flagged malformed.
|
||||
func mapV1ThresholdFormat(raw any) ThresholdFormat {
|
||||
s, _ := raw.(string)
|
||||
switch strings.ToLower(s) {
|
||||
case "background":
|
||||
return ThresholdFormatBackground
|
||||
case "text":
|
||||
return ThresholdFormatText
|
||||
}
|
||||
return ThresholdFormatText
|
||||
}
|
||||
443
pkg/types/dashboardtypes/perses_v1_to_v2_queries.go
Normal file
443
pkg/types/dashboardtypes/perses_v1_to_v2_queries.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Queries
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1WidgetQuery returns exactly one Query (per Spec.Validate). The kind
|
||||
// chosen depends on the v1 widget query shape:
|
||||
// - a single query (promql / clickhouse_sql / builder) → its native kind
|
||||
// - multiple queries → signoz/CompositeQuery
|
||||
//
|
||||
// A single query is never wrapped in a CompositeQuery; in particular List
|
||||
// panels accept only a bare signoz/BuilderQuery. Builder queries are routed
|
||||
// through qb.WrapInV5Envelope (in collectV1QueryEnvelopes), which translates v4
|
||||
// builder-field names (orderBy/selectColumns/dataSource) into their v5
|
||||
// equivalents and adds the `signal` field required by BuilderQuerySpec's
|
||||
// per-signal dispatch.
|
||||
func (d *v1Decoder) convertV1WidgetQuery(widget map[string]any, panelKind PanelPluginKind) []Query {
|
||||
envelopes, signal := d.collectV1QueryEnvelopes(widget, panelKind)
|
||||
if len(envelopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
// List panels accept only a bare BuilderQuery — never a CompositeQuery. Keep the
|
||||
// first query and drop the rest so a multi-query v1 list widget still migrates.
|
||||
if panelKind == PanelKindList && len(envelopes) > 1 {
|
||||
envelopes = envelopes[:1]
|
||||
}
|
||||
requestType := requestTypeForPanel(panelKind)
|
||||
|
||||
// A single query keeps its native kind — never wrapped in a CompositeQuery.
|
||||
if len(envelopes) == 1 {
|
||||
if q := singleQueryFromEnvelope(envelopes[0], requestType, signal); q != nil {
|
||||
return []Query{*q}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: wrap in CompositeQuery.
|
||||
composite, err := parseCompositeFromEnvelopes(envelopes)
|
||||
if err != nil || composite == nil {
|
||||
d.note("widget %q: could not build query from %d envelope(s): %s", d.readString(widget, "id"), len(envelopes), detailErr(err))
|
||||
return nil
|
||||
}
|
||||
return []Query{{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{Kind: QueryKindComposite, Spec: composite},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// dropUnrenderableQueries removes queries whose aggregation can't render (see
|
||||
// queryIsUnrenderable). If every query is unrenderable the result is empty, so the
|
||||
// widget produces no panel and is skipped silently (convertV1Panels) — matching v1,
|
||||
// which renders nothing.
|
||||
func dropUnrenderableQueries(queries []map[string]any) []map[string]any {
|
||||
renderable := make([]map[string]any, 0, len(queries))
|
||||
for _, q := range queries {
|
||||
if !queryIsUnrenderable(q) {
|
||||
renderable = append(renderable, q)
|
||||
}
|
||||
}
|
||||
return renderable
|
||||
}
|
||||
|
||||
// queryIsUnrenderable reports whether a builder query can't render because of its
|
||||
// aggregations: a metrics query with none or an empty metric name, or a logs/traces
|
||||
// query with an empty aggregation expression. No aggregations is valid for a raw
|
||||
// logs/traces query, so that isn't flagged.
|
||||
func queryIsUnrenderable(q map[string]any) bool {
|
||||
aggs, _ := q["aggregations"].([]any)
|
||||
switch signalFromDataSource(q["dataSource"]) {
|
||||
case telemetrytypes.SignalMetrics:
|
||||
if len(aggs) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range aggs {
|
||||
if agg, ok := a.(map[string]any); ok {
|
||||
if mn, _ := agg["metricName"].(string); mn == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
|
||||
for _, a := range aggs {
|
||||
if agg, ok := a.(map[string]any); ok {
|
||||
if expr, _ := agg["expression"].(string); expr == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// requestTypeForPanel maps a v2 panel plugin kind to the request type (result
|
||||
// shape) its queries produce. Mirrors the frontend's panelTypeToRequestType
|
||||
// (buildQueryRangeRequest.ts): time series for line/bar/histogram (histogram
|
||||
// bins client-side from raw time series, V1 parity), scalar for
|
||||
// number/pie/table, raw rows for list.
|
||||
func requestTypeForPanel(panelKind PanelPluginKind) qb.RequestType {
|
||||
switch panelKind {
|
||||
case PanelKindTimeSeries, PanelKindBarChart, PanelKindHistogram:
|
||||
return qb.RequestTypeTimeSeries
|
||||
case PanelKindNumber, PanelKindPieChart, PanelKindTable:
|
||||
return qb.RequestTypeScalar
|
||||
case PanelKindList:
|
||||
return qb.RequestTypeRaw
|
||||
}
|
||||
return qb.RequestTypeTimeSeries
|
||||
}
|
||||
|
||||
// collectV1QueryEnvelopes inspects widget.query.queryType and produces a
|
||||
// flattened list of v5-shaped envelopes. The returned signal is the dominant
|
||||
// builder signal (if any), used for typed builder-query dispatch.
|
||||
func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind PanelPluginKind) ([]map[string]any, telemetrytypes.Signal) {
|
||||
queryMap := d.readObject(widget, "query")
|
||||
if queryMap == nil {
|
||||
d.note("widget %q has no query map", d.readString(widget, "id"))
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
rowLimitPanel := panelKind == PanelKindList || panelKind == PanelKindTable
|
||||
// Raw (list) panels legitimately have no aggregation; every other panel needs one.
|
||||
needsAggregation := requestTypeForPanel(panelKind) != qb.RequestTypeRaw
|
||||
|
||||
queryType := d.readString(queryMap, "queryType")
|
||||
switch queryType {
|
||||
case "promql":
|
||||
promQueries := d.readObjects(queryMap, "promql")
|
||||
var out []map[string]any
|
||||
for _, q := range promQueries {
|
||||
// Drop empty queries; if none remain the widget produces no queries and is
|
||||
// skipped silently (convertV1Panels), as v1 renders nothing.
|
||||
if d.readString(q, "query") == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, promQLEnvelope(q))
|
||||
}
|
||||
return out, telemetrytypes.Signal{}
|
||||
|
||||
case "clickhouse_sql":
|
||||
chQueries := d.readObjects(queryMap, "clickhouse_sql")
|
||||
var out []map[string]any
|
||||
for _, q := range chQueries {
|
||||
// Drop empty queries; if none remain the widget produces no queries and is
|
||||
// skipped silently (convertV1Panels), as v1 renders nothing.
|
||||
if d.readString(q, "query") == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, clickhouseEnvelope(q))
|
||||
}
|
||||
return out, telemetrytypes.Signal{}
|
||||
|
||||
// "builder" plus a blank queryType: v1 defaults an unset type to builder, so a
|
||||
// missing value still carries a real builder query — try the builder path.
|
||||
case "builder", "":
|
||||
builder := d.readObject(queryMap, "builder")
|
||||
if builder == nil {
|
||||
d.note("widget %q has no builder data in the query map", d.readString(widget, "id"))
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
var out []map[string]any
|
||||
var signal telemetrytypes.Signal
|
||||
widgetType := d.readString(widget, "panelTypes")
|
||||
queries := d.readObjects(builder, "queryData")
|
||||
assignQueryDataNames(queries)
|
||||
for _, q := range queries {
|
||||
normalizePreV5QueryData(q, widgetType)
|
||||
normalizePreV5SelectColumns(q)
|
||||
normalizePreV5GroupBy(q)
|
||||
normalizePreV5PageSize(q, rowLimitPanel)
|
||||
normalizeQueryLimit(q)
|
||||
if needsAggregation {
|
||||
ensureDefaultAggregation(q)
|
||||
}
|
||||
}
|
||||
queries = dropUnrenderableQueries(queries)
|
||||
for _, q := range queries {
|
||||
name := d.readString(q, "queryName")
|
||||
out = append(out, qb.WrapInV5Envelope(name, q, string(qb.QueryTypeBuilder.StringValue())))
|
||||
if signal.IsZero() {
|
||||
signal = signalFromDataSource(q["dataSource"])
|
||||
}
|
||||
}
|
||||
formulas := d.readObjects(builder, "queryFormulas")
|
||||
assignMissingFormulaNames(formulas)
|
||||
for _, f := range formulas {
|
||||
normalizePreV5QueryData(f, widgetType)
|
||||
name := d.readString(f, "queryName")
|
||||
env := qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue()))
|
||||
// Drop a formula whose expression the validator rejects (blank/unparseable);
|
||||
// v1 tolerated it but v2 fails the whole query. Reuse the real validator
|
||||
// rather than reimplement it, as we do for functions.
|
||||
if !formulaEnvelopeIsValid(env) {
|
||||
continue
|
||||
}
|
||||
out = append(out, env)
|
||||
}
|
||||
for _, op := range d.readObjects(builder, "queryTraceOperator") {
|
||||
// A trace operator's expression is the operation itself ("A=>B->C") and is
|
||||
// required (ParseExpression rejects a blank one); drop it if empty.
|
||||
expression := d.readString(op, "expression")
|
||||
if expression == "" {
|
||||
continue
|
||||
}
|
||||
normalizePreV5QueryData(op, widgetType)
|
||||
normalizePreV5GroupBy(op)
|
||||
name := d.readString(op, "queryName")
|
||||
out = append(out, traceOperatorEnvelope(name, expression, op))
|
||||
}
|
||||
return out, signal
|
||||
default:
|
||||
d.note("widget %q has unknown queryType %q", d.readString(widget, "id"), queryType)
|
||||
}
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
|
||||
// traceOperatorEnvelope builds a v5 builder_trace_operator envelope. WrapInV5Envelope
|
||||
// would misclassify a trace operator as a formula (its name differs from its
|
||||
// expression, e.g. "A=>B->C"), so route the map through the builder-query path —
|
||||
// temporarily aligning expression with name to dodge that heuristic — then restore the
|
||||
// real expression, drop the builder-only signal (a trace operator's spec has no signal
|
||||
// field), and set the trace-operator type.
|
||||
func traceOperatorEnvelope(name, expression string, op map[string]any) map[string]any {
|
||||
op["expression"] = name
|
||||
env := qb.WrapInV5Envelope(name, op, string(qb.QueryTypeBuilder.StringValue()))
|
||||
if spec, ok := env["spec"].(map[string]any); ok {
|
||||
delete(spec, "signal")
|
||||
spec["expression"] = expression
|
||||
}
|
||||
env["type"] = string(qb.QueryTypeTraceOperator.StringValue())
|
||||
return env
|
||||
}
|
||||
|
||||
// maxQueries mirrors the frontend MAX_QUERIES; builder query names run A..Z.
|
||||
const maxQueries = 26
|
||||
|
||||
// assignQueryDataNames names builder data queries the way the frontend does: each
|
||||
// unnamed query takes the first unused A..Z, deduped against existing names. It also
|
||||
// forces expression == queryName, since a data query's expression is always its own
|
||||
// name and WrapInV5Envelope's name != expression heuristic would otherwise
|
||||
// misclassify the query as a formula.
|
||||
func assignQueryDataNames(queries []map[string]any) {
|
||||
taken := make(map[string]bool, len(queries))
|
||||
for _, q := range queries {
|
||||
if name, _ := q["queryName"].(string); name != "" {
|
||||
taken[name] = true
|
||||
}
|
||||
}
|
||||
for _, q := range queries {
|
||||
name, _ := q["queryName"].(string)
|
||||
if name == "" {
|
||||
for i := 0; i < maxQueries; i++ {
|
||||
candidate := string(rune('A' + i))
|
||||
if !taken[candidate] {
|
||||
name = candidate
|
||||
taken[candidate] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
q["queryName"] = name
|
||||
}
|
||||
q["expression"] = name
|
||||
}
|
||||
}
|
||||
|
||||
// formulaEnvelopeIsValid reports whether a builder_formula envelope's spec passes
|
||||
// QueryBuilderFormula.Validate (blank/unparseable expression, blank name, invalid
|
||||
// functions). Reuses the real validator rather than reimplementing it.
|
||||
func formulaEnvelopeIsValid(env map[string]any) bool {
|
||||
spec, ok := env["spec"].(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
raw, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var f qb.QueryBuilderFormula
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return false
|
||||
}
|
||||
return f.Validate() == nil
|
||||
}
|
||||
|
||||
// maxFormulas mirrors the frontend MAX_FORMULAS; formula names run F1..F20.
|
||||
const maxFormulas = 20
|
||||
|
||||
// assignMissingFormulaNames fills queryName for unnamed formulas, mirroring the
|
||||
// frontend: pick the first F{n} (n in 1..20) not already used by another formula.
|
||||
// Formulas that already have a name keep it.
|
||||
func assignMissingFormulaNames(formulas []map[string]any) {
|
||||
taken := make(map[string]bool, len(formulas))
|
||||
for _, f := range formulas {
|
||||
if name, _ := f["queryName"].(string); name != "" {
|
||||
taken[name] = true
|
||||
}
|
||||
}
|
||||
for _, f := range formulas {
|
||||
if name, _ := f["queryName"].(string); name != "" {
|
||||
continue
|
||||
}
|
||||
for i := 1; i <= maxFormulas; i++ {
|
||||
candidate := "F" + strconv.Itoa(i)
|
||||
if !taken[candidate] {
|
||||
f["queryName"] = candidate
|
||||
taken[candidate] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func promQLEnvelope(q map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"type": qb.QueryTypePromQL.StringValue(),
|
||||
"spec": map[string]any{
|
||||
"name": q["name"],
|
||||
"query": q["query"],
|
||||
"disabled": q["disabled"],
|
||||
"legend": q["legend"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func clickhouseEnvelope(q map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"type": qb.QueryTypeClickHouseSQL.StringValue(),
|
||||
"spec": map[string]any{
|
||||
"name": q["name"],
|
||||
"query": q["query"],
|
||||
"disabled": q["disabled"],
|
||||
"legend": q["legend"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// singleQueryFromEnvelope returns a typed Query for one envelope, using its
|
||||
// native query kind (promql/clickhouse_sql/builder) rather than wrapping it in
|
||||
// a CompositeQuery. A bare signoz/BuilderQuery is valid for every panel kind
|
||||
// and is the only kind List panels accept.
|
||||
func singleQueryFromEnvelope(envelope map[string]any, requestType qb.RequestType, signal telemetrytypes.Signal) *Query {
|
||||
t, _ := envelope["type"].(string)
|
||||
spec, _ := envelope["spec"].(map[string]any)
|
||||
switch t {
|
||||
case qb.QueryTypePromQL.StringValue():
|
||||
prom, err := decodeMapInto[qb.PromQuery](spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Query{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: prom.Name,
|
||||
Plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &prom},
|
||||
},
|
||||
}
|
||||
case qb.QueryTypeClickHouseSQL.StringValue():
|
||||
ch, err := decodeMapInto[qb.ClickHouseQuery](spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Query{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: ch.Name,
|
||||
Plugin: QueryPlugin{Kind: QueryKindClickHouseSQL, Spec: &ch},
|
||||
},
|
||||
}
|
||||
case qb.QueryTypeBuilder.StringValue():
|
||||
builderSpec := parseBuilderQuerySpec(spec, signal)
|
||||
if builderSpec == nil {
|
||||
return nil
|
||||
}
|
||||
name, _ := spec["name"].(string)
|
||||
return &Query{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: name,
|
||||
Plugin: QueryPlugin{Kind: QueryKindBuilder, Spec: &BuilderQuerySpec{Spec: builderSpec}},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCompositeFromEnvelopes(envelopes []map[string]any) (*CompositeQuerySpec, error) {
|
||||
bytes, err := json.Marshal(envelopes)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v1 query envelopes")
|
||||
}
|
||||
var parsed []qb.QueryEnvelope
|
||||
if err := json.Unmarshal(bytes, &parsed); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidWidgetQuery, "decode v5 query envelopes")
|
||||
}
|
||||
return &CompositeQuerySpec{Queries: parsed}, nil
|
||||
}
|
||||
|
||||
func parseBuilderQuerySpec(rawSpec any, signal telemetrytypes.Signal) any {
|
||||
spec, ok := rawSpec.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !signal.IsZero() {
|
||||
spec["signal"] = signal.StringValue()
|
||||
}
|
||||
bytes, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parsed, err := qb.UnmarshalBuilderQueryBySignal(bytes)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// signalFromDataSource maps a v1 data-source string to a v5 signal. Casing
|
||||
// varies by source: builder queries store lowercase ("traces"), while variable
|
||||
// `dynamicVariablesSource` stores capitalized ("Traces"), so match
|
||||
// case-insensitively. Unknown values (e.g. "All telemetry") map to the zero
|
||||
// Signal.
|
||||
func signalFromDataSource(raw any) telemetrytypes.Signal {
|
||||
s, _ := raw.(string)
|
||||
switch strings.ToLower(s) {
|
||||
case "traces":
|
||||
return telemetrytypes.SignalTraces
|
||||
case "logs":
|
||||
return telemetrytypes.SignalLogs
|
||||
case "metrics":
|
||||
return telemetrytypes.SignalMetrics
|
||||
}
|
||||
return telemetrytypes.Signal{}
|
||||
}
|
||||
431
pkg/types/dashboardtypes/perses_v1_to_v2_queries_malformed.go
Normal file
431
pkg/types/dashboardtypes/perses_v1_to_v2_queries_malformed.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Malformed-field normalization
|
||||
// ══════════════════════════════════════════════
|
||||
//
|
||||
// Pre-v5 query-body reshapes for dashboards whose bodies aren't actually v5-shaped
|
||||
// (e.g. stamped version:"v5" but never upgraded). The bulk of the upgrade is
|
||||
// delegated to transition.MigrateQueryDataShapeSafe (see normalizePreV5QueryData);
|
||||
// this file keeps only the reshapes it doesn't cover.
|
||||
|
||||
// preV5Migrator runs transition's shape-safe (idempotent) v4→v5 upgrade. Stateless
|
||||
// after construction, so a shared instance with a discard logger / no ambiguity
|
||||
// keys is fine.
|
||||
var preV5Migrator = transition.NewDashboardMigrateV5(slog.New(slog.DiscardHandler), nil, nil)
|
||||
|
||||
// normalizePreV5QueryData upgrades one builder queryData/formula in place: the
|
||||
// shared migrator, then a reshape of any existing aggregations[] it leaves alone.
|
||||
func normalizePreV5QueryData(query map[string]any, widgetType string) {
|
||||
dropLegacyFilter(query)
|
||||
preV5Migrator.MigrateQueryDataShapeSafe(context.Background(), query, widgetType)
|
||||
normalizePreV5LogTraceAggregations(query)
|
||||
normalizeMetricAggregations(query)
|
||||
normalizeOrderByKeys(query)
|
||||
normalizeFunctionArgs(query)
|
||||
dropInvalidFunctions(query)
|
||||
}
|
||||
|
||||
// dropInvalidFunctions removes any function the v5 validator would reject — an unknown
|
||||
// name, or a missing/uncastable required arg (see Function.Validate). v1 tolerated these
|
||||
// but v2 fails the whole query, so we drop just the offending function. Runs after
|
||||
// normalizeFunctionArgs so a merely double-wrapped (but otherwise valid) function isn't
|
||||
// lost.
|
||||
func dropInvalidFunctions(query map[string]any) {
|
||||
fns, ok := query["functions"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
kept := make([]any, 0, len(fns))
|
||||
for _, f := range fns {
|
||||
raw, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var fn qb.Function
|
||||
if err := json.Unmarshal(raw, &fn); err != nil {
|
||||
continue
|
||||
}
|
||||
if fn.Validate() != nil {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, f)
|
||||
}
|
||||
query["functions"] = kept
|
||||
}
|
||||
|
||||
// normalizeFunctionArgs collapses a doubly-wrapped function arg to a scalar. The
|
||||
// v4→v5 migration that runs before ConvertV1ToV2 (transition.updateQueryData) wraps every arg as
|
||||
// {name, value} without checking whether it's already a v5 arg, so a body that was
|
||||
// already v5 comes back as {value:{value:60}} and fails validation ("must be a floating
|
||||
// value"). We can't guard it at the source — transition's Migrate is shared and left
|
||||
// untouched — so unwrap one level of {value:...} nesting here.
|
||||
func normalizeFunctionArgs(query map[string]any) {
|
||||
fns, ok := query["functions"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, f := range fns {
|
||||
fn, ok := f.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
args, ok := fn["args"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, a := range args {
|
||||
arg, ok := a.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if inner, ok := arg["value"].(map[string]any); ok {
|
||||
if v, ok := inner["value"]; ok {
|
||||
arg["value"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// malformedOrderByValueKeys are v4 order-by columnNames meaning "order by the aggregation value"
|
||||
// that the v5 aggregation validator rejects (validateOrderByForAggregation). All resolve
|
||||
// to the same aggregation key. Add more as they surface. The frontend passes these
|
||||
// through (the query-service resolves them), but the v2 dashboard validator only accepts
|
||||
// a real aggregation key.
|
||||
var malformedOrderByValueKeys = map[string]bool{
|
||||
"#SIGNOZ_VALUE": true,
|
||||
"A": true,
|
||||
"A.count()": true,
|
||||
"__result": true,
|
||||
"value": true,
|
||||
"A.p99(duration_nano)": true,
|
||||
"aws_Kafka_MessagesInPerSec_max": true,
|
||||
"byte_in_count": true,
|
||||
"(http_server_request_duration_ms.bucket)": true,
|
||||
}
|
||||
|
||||
// normalizeOrderByKeys rewrites any orderBy columnName in orderByValueKeys to the
|
||||
// v5-valid aggregation key. Left untouched if the key can't resolve (no aggregation to
|
||||
// name).
|
||||
func normalizeOrderByKeys(query map[string]any) {
|
||||
orders, ok := query["orderBy"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key, ok := aggregationOrderKey(query)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, o := range orders {
|
||||
order, ok := o.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cn, _ := order["columnName"].(string); malformedOrderByValueKeys[cn] {
|
||||
order["columnName"] = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// aggregationOrderKey names the first aggregation the way validateOrderByForAggregation
|
||||
// expects: "space(metricName)" for metrics, the expression for logs/traces.
|
||||
func aggregationOrderKey(query map[string]any) (string, bool) {
|
||||
aggs, ok := query["aggregations"].([]any)
|
||||
if !ok || len(aggs) == 0 {
|
||||
return "", false
|
||||
}
|
||||
agg, ok := aggs[0].(map[string]any)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if signalFromDataSource(query["dataSource"]) == telemetrytypes.SignalMetrics {
|
||||
metricName, _ := agg["metricName"].(string)
|
||||
space, _ := agg["spaceAggregation"].(string)
|
||||
if metricName == "" || space == "" {
|
||||
return "", false
|
||||
}
|
||||
return space + "(" + metricName + ")", true
|
||||
}
|
||||
expr, _ := agg["expression"].(string)
|
||||
if expr == "" {
|
||||
return "", false
|
||||
}
|
||||
return expr, true
|
||||
}
|
||||
|
||||
// dropLegacyFilter removes a v4-shaped filter ({items, op}) stored under the v5
|
||||
// `filter` key. The v5 filter is {expression}; the migrator only rewrites the v4
|
||||
// `filters` key and skips when `filter` is present, so this stale shape would reach
|
||||
// WrapInV5Envelope and fail v5 validation. The v1 UI ignores it — it types
|
||||
// IBuilderQuery.filter as {expression} (frontend queryBuilderData.ts, filter?: Filter)
|
||||
// and only ever reads filter.expression, so items/op go unread. We drop it before the
|
||||
// migrator, which can then rebuild `filter` from `filters` if present.
|
||||
func dropLegacyFilter(query map[string]any) {
|
||||
filter, ok := query["filter"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, hasItems := filter["items"]
|
||||
_, hasOp := filter["op"]
|
||||
if hasItems || hasOp {
|
||||
delete(query, "filter")
|
||||
}
|
||||
}
|
||||
|
||||
// metricAggregationFields are the JSON keys a metric aggregation accepts (see
|
||||
// MetricAggregation). The decoder is strict, so any other key (e.g. a logs/traces
|
||||
// style `expression`) is rejected as an unknown field.
|
||||
var metricAggregationFields = map[string]bool{
|
||||
"metricName": true,
|
||||
"temporality": true,
|
||||
"timeAggregation": true,
|
||||
"spaceAggregation": true,
|
||||
"comparisonSpaceAggregationParam": true,
|
||||
"reduceTo": true,
|
||||
}
|
||||
|
||||
// normalizeMetricAggregations reshapes a metric query's aggregations to the shape v5
|
||||
// expects. v1 bodies sometimes carry a logs/traces-style aggregation ({expression});
|
||||
// the frontend ignores expression for metrics and builds from the metric fields
|
||||
// (createAggregation, prepareQueryRangePayloadV5.ts), so we drop every non-metric
|
||||
// key. A dropped expression leaves metricName empty and the widget is skipped later
|
||||
// (isUnrenderableMetricQuery), matching what v1 renders.
|
||||
//
|
||||
// It also defaults an invalid spaceAggregation to "sum": v1 bodies often leave it
|
||||
// empty or carry a stale value, which fails validation (SpaceAggregation.IsValid). A
|
||||
// valid value (including a histogram percentile) is left alone; the metric type isn't
|
||||
// in the body, so we can't prefer a percentile default for histograms.
|
||||
func normalizeMetricAggregations(query map[string]any) {
|
||||
if signalFromDataSource(query["dataSource"]) != telemetrytypes.SignalMetrics {
|
||||
return
|
||||
}
|
||||
aggs, ok := query["aggregations"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, a := range aggs {
|
||||
agg, ok := a.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for k := range agg {
|
||||
if !metricAggregationFields[k] {
|
||||
delete(agg, k)
|
||||
}
|
||||
}
|
||||
sa, _ := agg["spaceAggregation"].(string)
|
||||
if !(metrictypes.SpaceAggregation{String: valuer.NewString(sa)}).IsValid() {
|
||||
agg["spaceAggregation"] = metrictypes.SpaceAggregationSum.StringValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizePreV5LogTraceAggregations reshapes an existing logs/traces aggregations[]
|
||||
// via parseAggregations (extract func(args), lift inline "as alias", split
|
||||
// multi-part, drop metric-only fields; empty → count()). Covers the case the
|
||||
// migrator skips: it builds from flat fields but leaves a present-but-malformed
|
||||
// aggregations[] alone. A query with none is left as-is.
|
||||
func normalizePreV5LogTraceAggregations(query map[string]any) {
|
||||
switch signalFromDataSource(query["dataSource"]) {
|
||||
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
|
||||
default:
|
||||
return
|
||||
}
|
||||
aggs, ok := query["aggregations"].([]any)
|
||||
if !ok || len(aggs) == 0 {
|
||||
return
|
||||
}
|
||||
out := make([]any, 0, len(aggs))
|
||||
for _, a := range aggs {
|
||||
agg, ok := a.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
expr, _ := agg["expression"].(string)
|
||||
alias, _ := agg["alias"].(string)
|
||||
parsed := parseAggregations(expr, alias)
|
||||
if len(parsed) == 0 {
|
||||
parsed = []any{map[string]any{"expression": "count()"}}
|
||||
}
|
||||
out = append(out, parsed...)
|
||||
}
|
||||
query["aggregations"] = out
|
||||
}
|
||||
|
||||
// ensureDefaultAggregation defaults an empty logs/traces aggregations[] to count(),
|
||||
// mirroring the frontend. Callers gate this to aggregation panels. Metrics are skipped:
|
||||
// count() can't stand in for a missing metricName.
|
||||
func ensureDefaultAggregation(query map[string]any) {
|
||||
switch signalFromDataSource(query["dataSource"]) {
|
||||
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
|
||||
default:
|
||||
return
|
||||
}
|
||||
if aggs, ok := query["aggregations"].([]any); ok && len(aggs) > 0 {
|
||||
return
|
||||
}
|
||||
query["aggregations"] = []any{map[string]any{"expression": "count()"}}
|
||||
}
|
||||
|
||||
// aggExprRe matches one "func(args)" with an optional "as alias". Mirrors the
|
||||
// frontend's parseAggregations regex; matching only well-formed func(args)
|
||||
// discards trailing junk ("sum(x) ) )" → "sum(x)").
|
||||
var aggExprRe = regexp.MustCompile(`([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+('[^']*'|"[^"]*"|[a-zA-Z0-9_-]+))?`)
|
||||
|
||||
// aggExprNestedRe is a backup for aggExprRe that tolerates one level of nested
|
||||
// parens in args (rate(count())). HACK: the flat aggExprRe (and the frontend it
|
||||
// mirrors) truncates such exprs to an unbalanced "rate(count()"; the UI fails
|
||||
// these today, so this is best-effort beyond v1. Tried only when the flat match
|
||||
// comes back unbalanced.
|
||||
var aggExprNestedRe = regexp.MustCompile(`([a-zA-Z0-9_]+\((?:[a-zA-Z0-9_]+\([^()]*\)|[^()])*\))(?:\s*as\s+('[^']*'|"[^"]*"|[a-zA-Z0-9_-]+))?`)
|
||||
|
||||
// parseAggregations pulls every func(args) (with inline or passed-through alias,
|
||||
// quotes stripped) out of a v1 expression. Mirrors the frontend's
|
||||
// parseAggregations; empty result if none.
|
||||
func parseAggregations(expression, availableAlias string) []any {
|
||||
matches := aggExprRe.FindAllStringSubmatch(expression, -1)
|
||||
if hasUnbalancedParens(matches) {
|
||||
matches = aggExprNestedRe.FindAllStringSubmatch(expression, -1)
|
||||
}
|
||||
out := make([]any, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
alias := m[2]
|
||||
if alias == "" {
|
||||
alias = availableAlias
|
||||
}
|
||||
agg := map[string]any{"expression": m[1]}
|
||||
if alias != "" {
|
||||
agg["alias"] = strings.Trim(alias, `'"`)
|
||||
}
|
||||
out = append(out, agg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasUnbalancedParens reports whether any matched expression has mismatched
|
||||
// parens — the signature of aggExprRe truncating a nested expr ("rate(count()").
|
||||
func hasUnbalancedParens(matches [][]string) bool {
|
||||
for _, m := range matches {
|
||||
if strings.Count(m[1], "(") != strings.Count(m[1], ")") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizePreV5SelectColumns / normalizePreV5GroupBy let WrapInV5Envelope (which
|
||||
// reads the old {key,dataType,type}) handle selectColumns/groupBy stored the v5 way
|
||||
// ({name,…}) — see backfillPreV5FieldKeys. Inverse of normalizePreV5FieldKeys (the
|
||||
// two consumers want opposite shapes).
|
||||
func normalizePreV5SelectColumns(query map[string]any) {
|
||||
if cols, ok := query["selectColumns"].([]any); ok {
|
||||
query["selectColumns"] = backfillPreV5FieldKeys(cols)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePreV5GroupBy(query map[string]any) {
|
||||
if gb, ok := query["groupBy"].([]any); ok {
|
||||
query["groupBy"] = backfillPreV5FieldKeys(gb)
|
||||
}
|
||||
}
|
||||
|
||||
// backfillPreV5FieldKeys copies v5 field names (name/fieldDataType/fieldContext)
|
||||
// down to their v4 equivalents (key/dataType/type) so WrapInV5Envelope, which reads
|
||||
// the v4 names, sees a field stored the v5 way. Fields with no resolvable key are
|
||||
// dropped.
|
||||
func backfillPreV5FieldKeys(fields []any) []any {
|
||||
out := make([]any, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
field, ok := f.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := field["key"]; !ok {
|
||||
if name, ok := field["name"]; ok {
|
||||
field["key"] = name
|
||||
}
|
||||
}
|
||||
if _, ok := field["dataType"]; !ok {
|
||||
if fdt, ok := field["fieldDataType"]; ok {
|
||||
field["dataType"] = fdt
|
||||
}
|
||||
}
|
||||
if _, ok := field["type"]; !ok {
|
||||
if fc, ok := field["fieldContext"]; ok {
|
||||
field["type"] = fc
|
||||
}
|
||||
}
|
||||
if key, _ := field["key"].(string); key == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, field)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizePreV5FieldKeys renames list-panel field keys {key,dataType,type} →
|
||||
// {name,fieldDataType,fieldContext} in place (as WrapInV5Envelope does for
|
||||
// groupBy/orderBy). Entries already carrying "name" are left as-is.
|
||||
func normalizePreV5FieldKeys(fields []any) {
|
||||
for _, f := range fields {
|
||||
field, ok := f.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, hasName := field["name"]; hasName {
|
||||
continue
|
||||
}
|
||||
if key, ok := field["key"]; ok {
|
||||
field["name"] = key
|
||||
}
|
||||
if dataType, ok := field["dataType"]; ok {
|
||||
field["fieldDataType"] = dataType
|
||||
}
|
||||
if typ, ok := field["type"]; ok {
|
||||
field["fieldContext"] = typ
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizePreV5PageSize backfills limit from the legacy pageSize (frontend's
|
||||
// `limit || pageSize`), for row-limited panels (list/table) only. Leaves a query
|
||||
// that already has limit, or a non-row-limited panel, untouched.
|
||||
func normalizePreV5PageSize(query map[string]any, rowLimitPanel bool) {
|
||||
if !rowLimitPanel {
|
||||
return
|
||||
}
|
||||
if limit, ok := query["limit"]; ok && limit != nil {
|
||||
return
|
||||
}
|
||||
if ps, ok := query["pageSize"]; ok {
|
||||
query["limit"] = ps
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeQueryLimit drops a limit above the v5 maximum (MaxQueryLimit); v1 allowed
|
||||
// larger/unbounded limits, and an over-max value fails validation. Removing it leaves
|
||||
// the query unlimited (the field is optional).
|
||||
func normalizeQueryLimit(query map[string]any) {
|
||||
limit, ok := coerceFloat(query["limit"])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if limit > qb.MaxQueryLimit {
|
||||
delete(query, "limit")
|
||||
}
|
||||
}
|
||||
122
pkg/types/dashboardtypes/perses_v1_to_v2_tags.go
Normal file
122
pkg/types/dashboardtypes/perses_v1_to_v2_tags.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Tags
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// v1 carries tags as a flat []string; v2 tags are (key, value) pairs. Each v1
|
||||
// string is normalized into a pair (separator split, empty-side fallback,
|
||||
// reserved-key prefix, `/` scrub). Tags that normalize to the same
|
||||
// (lower(key), lower(value)) within a dashboard are collapsed, first occurrence
|
||||
// winning the display casing.
|
||||
//
|
||||
// Characters still illegal after normalization (spaces, punctuation) are molded
|
||||
// to fit the tag validators: disallowed runs collapse to "_" (see moldTagField).
|
||||
|
||||
// defaultV1TagKey is the key assigned when a v1 tag string has no usable
|
||||
// separator (or one side of the split is empty).
|
||||
const defaultV1TagKey = "tag"
|
||||
|
||||
func (d *v1Decoder) convertV1TagsForOrg(orgID valuer.UUID, raw any) []*tagtypes.Tag {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
rawTagsList, ok := raw.([]any)
|
||||
if !ok {
|
||||
d.noteMalformedField("tags", raw)
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(rawTagsList))
|
||||
tagsV2 := make([]*tagtypes.Tag, 0, len(rawTagsList))
|
||||
for i, rawTag := range rawTagsList {
|
||||
s, ok := rawTag.(string)
|
||||
if !ok {
|
||||
d.noteMalformedField(fmt.Sprintf("tags[%d]", i), rawTag)
|
||||
continue
|
||||
}
|
||||
key, value, ok := normalizeV1Tag(s)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dedupKey := strings.ToLower(key) + "\x00" + strings.ToLower(value)
|
||||
if _, dup := seen[dedupKey]; dup {
|
||||
continue
|
||||
}
|
||||
seen[dedupKey] = struct{}{}
|
||||
tagsV2 = append(tagsV2, tagtypes.NewTag(orgID, coretypes.KindDashboard, key, value))
|
||||
}
|
||||
return tagsV2
|
||||
}
|
||||
|
||||
// normalizeV1Tag derives a (key, value) pair from one v1 tag string. After
|
||||
// splitting and molding both sides, a lone survivor becomes a value under the
|
||||
// default key; ok is false if neither survives.
|
||||
func normalizeV1Tag(s string) (string, string, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var rawKey, rawValue string
|
||||
switch {
|
||||
case strings.Contains(s, ":"):
|
||||
rawKey, rawValue, _ = strings.Cut(s, ":")
|
||||
// Only the first ":" separates key from value; collapse the rest.
|
||||
rawValue = strings.ReplaceAll(rawValue, ":", "_")
|
||||
case strings.Contains(s, "/"):
|
||||
rawKey, rawValue, _ = strings.Cut(s, "/")
|
||||
default:
|
||||
rawValue = s
|
||||
}
|
||||
rawKey = strings.TrimSpace(rawKey)
|
||||
rawValue = strings.TrimSpace(rawValue)
|
||||
|
||||
// Reserved-key collision: prefix "_" so the list-query DSL stays unambiguous.
|
||||
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(rawKey))]; rawKey != "" && reserved {
|
||||
rawKey = "_" + rawKey
|
||||
}
|
||||
|
||||
key := moldTagField(rawKey, tagKeyDisallowed, tagKeyNotLead, tagtypes.MAX_LEN_TAG_KEY)
|
||||
value := moldTagField(rawValue, tagValueDisallowed, nil, tagtypes.MAX_LEN_TAG_VALUE)
|
||||
switch {
|
||||
case key == "" && value == "":
|
||||
return "", "", false
|
||||
case key == "":
|
||||
return defaultV1TagKey, value, true
|
||||
case value == "":
|
||||
return defaultV1TagKey, key, true
|
||||
default:
|
||||
return key, value, true
|
||||
}
|
||||
}
|
||||
|
||||
// Inverse of tagKeyRegex/tagValueRegex ("/" always rejected); tagKeyNotLead
|
||||
// matches a bad first char for a key. TestMoldedV1TagsPassValidation guards drift.
|
||||
var (
|
||||
tagKeyDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:-]+`)
|
||||
tagValueDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:.+=-]+`)
|
||||
tagKeyNotLead = regexp.MustCompile(`^[^a-zA-Z$_@{#]`)
|
||||
)
|
||||
|
||||
// moldTagField collapses disallowed runs to "_", prefixes "_" if notLead hits
|
||||
// the first char, and caps at max. Keeps a leading "_", trims a trailing one.
|
||||
func moldTagField(s string, disallowed, notLead *regexp.Regexp, max int) string {
|
||||
s = strings.TrimRight(disallowed.ReplaceAllString(s, "_"), "_")
|
||||
if s != "" && notLead != nil && notLead.MatchString(s) {
|
||||
s = "_" + s
|
||||
}
|
||||
if len(s) > max {
|
||||
s = strings.TrimRight(s[:max], "_")
|
||||
}
|
||||
return s
|
||||
}
|
||||
1531
pkg/types/dashboardtypes/perses_v1_to_v2_test.go
Normal file
1531
pkg/types/dashboardtypes/perses_v1_to_v2_test.go
Normal file
File diff suppressed because it is too large
Load Diff
219
pkg/types/dashboardtypes/perses_v1_to_v2_variables.go
Normal file
219
pkg/types/dashboardtypes/perses_v1_to_v2_variables.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/perses/spec/go/dashboard/variable"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Variables
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Variables walks the v1 `variables` map (UUID-keyed) and produces an
|
||||
// ordered []Variable. Variables sort by `order` first, then by id for stable
|
||||
// output. v1 variable types map as follows:
|
||||
//
|
||||
// QUERY → ListVariable + signoz/QueryVariable
|
||||
// CUSTOM → ListVariable + signoz/CustomVariable
|
||||
// DYNAMIC → ListVariable + signoz/DynamicVariable
|
||||
// TEXTBOX → TextVariable
|
||||
func (d *v1Decoder) convertV1Variables(raw any) []Variable {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
rawVariablesMap, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
// v1 sometimes stores variables as a list. The frontend consumes it via
|
||||
// Object.entries/keys, which for an array yields the stringified index as the
|
||||
// key, so mirror that: [{...}] is treated as {"0":{...}}. An empty list is
|
||||
// simply "no variables".
|
||||
rawSlice, isSlice := raw.([]any)
|
||||
if !isSlice {
|
||||
d.noteMalformedField("variables", raw)
|
||||
return nil
|
||||
}
|
||||
rawVariablesMap = make(map[string]any, len(rawSlice))
|
||||
for i, v := range rawSlice {
|
||||
rawVariablesMap[strconv.Itoa(i)] = v
|
||||
}
|
||||
}
|
||||
type ordered struct {
|
||||
variableID string
|
||||
variableContent map[string]any
|
||||
order float64
|
||||
}
|
||||
entries := make([]ordered, 0, len(rawVariablesMap))
|
||||
for variableID, variableContentRaw := range rawVariablesMap {
|
||||
variableContent, ok := variableContentRaw.(map[string]any)
|
||||
if !ok {
|
||||
// A variable whose content isn't an object (e.g. a stray "list" array) can't
|
||||
// render in the current UI, so it's useless — skip it instead of failing the
|
||||
// migration.
|
||||
continue
|
||||
}
|
||||
entries = append(entries, ordered{variableID: variableID, variableContent: variableContent, order: d.readFloat(variableContent, "order")})
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].order != entries[j].order {
|
||||
return entries[i].order < entries[j].order
|
||||
}
|
||||
return entries[i].variableID < entries[j].variableID
|
||||
})
|
||||
|
||||
variablesV2 := make([]Variable, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
v, ok := d.convertV1Variable(e.variableContent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
variablesV2 = append(variablesV2, v)
|
||||
}
|
||||
return variablesV2
|
||||
}
|
||||
|
||||
func (d *v1Decoder) convertV1Variable(v map[string]any) (Variable, bool) {
|
||||
name := d.readString(v, "name")
|
||||
if name == "" {
|
||||
return Variable{}, false
|
||||
}
|
||||
description := d.readString(v, "description")
|
||||
// v1 stores the type upper-cased (QUERY/CUSTOM/…); tolerate any casing.
|
||||
kind := strings.ToUpper(d.readString(v, "type"))
|
||||
|
||||
switch kind {
|
||||
case "TEXTBOX":
|
||||
spec := &TextVariableSpec{
|
||||
Display: Display{Name: clipName(name, MaxDisplayNameLen), Description: description},
|
||||
Value: d.readString(v, "textboxValue"),
|
||||
Name: name,
|
||||
}
|
||||
return Variable{Kind: variable.KindText, Spec: spec}, true
|
||||
|
||||
case "QUERY", "CUSTOM", "DYNAMIC":
|
||||
// Drop (don't fail on) a dynamic variable with no attribute — it can't resolve.
|
||||
if kind == "DYNAMIC" && d.readString(v, "dynamicVariablesAttribute") == "" {
|
||||
return Variable{}, false
|
||||
}
|
||||
// Drop a custom variable with no recoverable option list — v2 requires one.
|
||||
if kind == "CUSTOM" && d.readString(v, "customValue") == "" && d.readString(v, "selectedValue") == "" && d.readString(v, "defaultValue") == "" {
|
||||
return Variable{}, false
|
||||
}
|
||||
// Drop a query variable with no query — it can't resolve.
|
||||
if kind == "QUERY" && d.readString(v, "queryValue") == "" {
|
||||
return Variable{}, false
|
||||
}
|
||||
listSpec := &ListVariableSpec{
|
||||
Display: Display{Name: clipName(name, MaxDisplayNameLen), Description: description},
|
||||
AllowAllValue: d.readBool(v, "showALLOption"),
|
||||
AllowMultiple: d.readBool(v, "multiSelect"),
|
||||
CustomAllValue: d.readString(v, "customAllValue"),
|
||||
CapturingRegexp: d.readString(v, "capturingRegexp"),
|
||||
Sort: mapV1Sort(v["sort"]),
|
||||
Plugin: d.variablePluginFor(kind, v),
|
||||
Name: name,
|
||||
}
|
||||
if dv := mapV1VariableDefault(v, listSpec.AllowMultiple); dv != nil {
|
||||
listSpec.DefaultValue = dv
|
||||
}
|
||||
return Variable{Kind: variable.KindList, Spec: listSpec}, true
|
||||
|
||||
case "":
|
||||
// v1 sometimes stores a variable with no type; it can't render, so drop it
|
||||
// silently rather than flagging it malformed.
|
||||
return Variable{}, false
|
||||
|
||||
default:
|
||||
d.note("variable %q has unknown type %q", name, kind)
|
||||
return Variable{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func (d *v1Decoder) variablePluginFor(kind string, v map[string]any) VariablePlugin {
|
||||
switch kind {
|
||||
case "QUERY":
|
||||
return VariablePlugin{
|
||||
Kind: VariableKindQuery,
|
||||
Spec: &QueryVariableSpec{QueryValue: d.readString(v, "queryValue")},
|
||||
}
|
||||
case "CUSTOM":
|
||||
// Some v1 dashboards stored the option list in selectedValue/defaultValue
|
||||
// instead of customValue; fall back so the variable survives migration.
|
||||
customValue := d.readString(v, "customValue")
|
||||
if customValue == "" {
|
||||
customValue = d.readString(v, "selectedValue")
|
||||
}
|
||||
if customValue == "" {
|
||||
customValue = d.readString(v, "defaultValue")
|
||||
}
|
||||
return VariablePlugin{
|
||||
Kind: VariableKindCustom,
|
||||
Spec: &CustomVariableSpec{CustomValue: customValue},
|
||||
}
|
||||
case "DYNAMIC":
|
||||
spec := &DynamicVariableSpec{Name: d.readString(v, "dynamicVariablesAttribute")}
|
||||
if signal := signalFromDataSource(v["dynamicVariablesSource"]); !signal.IsZero() {
|
||||
spec.Signal = signal
|
||||
}
|
||||
return VariablePlugin{Kind: VariableKindDynamic, Spec: spec}
|
||||
}
|
||||
return VariablePlugin{}
|
||||
}
|
||||
|
||||
// mapV1VariableDefault reads selectedValue/defaultValue, both polymorphic
|
||||
// (string|array), so it indexes the raw value and lets defaultValueFromAny
|
||||
// type-switch — no typed accessor, intentionally lenient.
|
||||
func mapV1VariableDefault(v map[string]any, allowMultiple bool) *VariableDefaultValue {
|
||||
if raw, ok := v["selectedValue"]; ok {
|
||||
return defaultValueFromAny(raw, allowMultiple)
|
||||
}
|
||||
if raw, ok := v["defaultValue"]; ok {
|
||||
return defaultValueFromAny(raw, allowMultiple)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultValueFromAny(raw any, allowMultiple bool) *VariableDefaultValue {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return &VariableDefaultValue{variable.DefaultValue{SingleValue: v}}
|
||||
case []any:
|
||||
if len(v) == 0 {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
values = append(values, s)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
// A single-select variable can't carry a list default; collapse a lone value.
|
||||
if !allowMultiple && len(values) == 1 {
|
||||
return &VariableDefaultValue{variable.DefaultValue{SingleValue: values[0]}}
|
||||
}
|
||||
return &VariableDefaultValue{variable.DefaultValue{SliceValues: values}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapV1Sort reads the raw value (not via readString) so a non-string sort — some v1
|
||||
// dashboards store it as a number (e.g. 0) — defaults to none silently instead of
|
||||
// being flagged malformed.
|
||||
func mapV1Sort(raw any) ListVariableSpecSort {
|
||||
s, _ := raw.(string)
|
||||
switch s {
|
||||
case "ASC":
|
||||
return SortAlphabeticalAsc
|
||||
case "DESC":
|
||||
return SortAlphabeticalDesc
|
||||
}
|
||||
return ListVariableSpecSort{} // zero (omitzero) — SortNone is the implicit default
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
// WrapInV5Envelope translates a single v4 builder query/formula map into a
|
||||
// v5 query envelope ({"type": ..., "spec": ...}). It is a pure shape transform
|
||||
// over untyped maps: v4 builder field names (groupBy/orderBy/selectColumns/
|
||||
// dataSource) are rewritten to their v5 equivalents and a `signal` is derived
|
||||
// from the data source. queryType selects the envelope type, except a formula
|
||||
// (detected when name != queryMap["expression"]) is always emitted as
|
||||
// "builder_formula".
|
||||
//
|
||||
// Migration code (pkg/transition) and the v1→v2 dashboard conversion both
|
||||
// produce v5 envelopes, so this lives here with the v5 query types rather than
|
||||
// in an infra-level package.
|
||||
func WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
|
||||
// Create a properly structured v5 query
|
||||
v5Query := map[string]any{
|
||||
"name": name,
|
||||
"disabled": queryMap["disabled"],
|
||||
"legend": queryMap["legend"],
|
||||
}
|
||||
|
||||
if name != queryMap["expression"] {
|
||||
// formula
|
||||
queryType = "builder_formula"
|
||||
v5Query["expression"] = queryMap["expression"]
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
}
|
||||
|
||||
// Add signal based on data source
|
||||
if dataSource, ok := queryMap["dataSource"].(string); ok {
|
||||
switch dataSource {
|
||||
case "traces":
|
||||
v5Query["signal"] = "traces"
|
||||
case "logs":
|
||||
v5Query["signal"] = "logs"
|
||||
case "metrics":
|
||||
v5Query["signal"] = "metrics"
|
||||
}
|
||||
}
|
||||
|
||||
if stepInterval, ok := queryMap["stepInterval"]; ok {
|
||||
v5Query["stepInterval"] = stepInterval
|
||||
}
|
||||
|
||||
if aggregations, ok := queryMap["aggregations"]; ok {
|
||||
v5Query["aggregations"] = aggregations
|
||||
}
|
||||
|
||||
if filter, ok := queryMap["filter"]; ok {
|
||||
v5Query["filter"] = filter
|
||||
}
|
||||
|
||||
// Copy groupBy with proper structure
|
||||
if groupBy, ok := queryMap["groupBy"].([]any); ok {
|
||||
v5GroupBy := make([]any, len(groupBy))
|
||||
for i, gb := range groupBy {
|
||||
if gbMap, ok := gb.(map[string]any); ok {
|
||||
v5GroupBy[i] = map[string]any{
|
||||
"name": gbMap["key"],
|
||||
"fieldDataType": gbMap["dataType"],
|
||||
"fieldContext": gbMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["groupBy"] = v5GroupBy
|
||||
}
|
||||
|
||||
// Copy orderBy with proper structure
|
||||
if orderBy, ok := queryMap["orderBy"].([]any); ok {
|
||||
v5OrderBy := make([]any, len(orderBy))
|
||||
for i, ob := range orderBy {
|
||||
if obMap, ok := ob.(map[string]any); ok {
|
||||
v5OrderBy[i] = map[string]any{
|
||||
"key": map[string]any{
|
||||
"name": obMap["columnName"],
|
||||
"fieldDataType": obMap["dataType"],
|
||||
"fieldContext": obMap["type"],
|
||||
},
|
||||
"direction": obMap["order"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["order"] = v5OrderBy
|
||||
}
|
||||
|
||||
// Copy selectColumns as selectFields
|
||||
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
|
||||
v5SelectFields := make([]any, len(selectColumns))
|
||||
for i, col := range selectColumns {
|
||||
if colMap, ok := col.(map[string]any); ok {
|
||||
v5SelectFields[i] = map[string]any{
|
||||
"name": colMap["key"],
|
||||
"fieldDataType": colMap["dataType"],
|
||||
"fieldContext": colMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["selectFields"] = v5SelectFields
|
||||
}
|
||||
|
||||
// Copy limit and offset
|
||||
if limit, ok := queryMap["limit"]; ok {
|
||||
v5Query["limit"] = limit
|
||||
}
|
||||
if offset, ok := queryMap["offset"]; ok {
|
||||
v5Query["offset"] = offset
|
||||
}
|
||||
|
||||
if having, ok := queryMap["having"]; ok {
|
||||
v5Query["having"] = having
|
||||
}
|
||||
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user