diff --git a/package-lock.json b/package-lock.json index 6eab554..73d1d31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sift-grafana-datasource", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sift-grafana-datasource", - "version": "1.3.0", + "version": "1.3.1", "license": "Apache-2.0", "dependencies": { "@emotion/css": "11.10.6", diff --git a/package.json b/package.json index 2e120fc..ae576d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sift-grafana-datasource", - "version": "1.3.0", + "version": "1.3.1", "description": "", "scripts": { "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index d89c813..955c3f4 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -945,11 +945,111 @@ func generateDataFrame(responseData []queryResponseData, calculatedChannelKeys m frame.Meta = &data.FrameMeta{ Type: data.FrameTypeTimeSeriesWide, TypeVersion: data.FrameTypeVersion{0, 1}, + Notices: []data.Notice{}, } + // Check for precision loss in INT64/UINT64 fields + checkInt64PrecisionLoss(frame) + return frame, nil } +// checkInt64PrecisionLoss validates INT64/UINT64 fields for values outside JavaScript's safe integer range +// and attaches warnings to the frame if precision loss may occur in the frontend. +func checkInt64PrecisionLoss(frame *data.Frame) { + // JavaScript's safe integer range: ±2^53-1 + const jsSafeIntMax int64 = 9007199254740991 // 2^53 - 1 + const jsSafeIntMin int64 = -9007199254740991 // -(2^53 - 1) + const jsSafeUintMax uint64 = 9007199254740991 + const warningFormat = "Field '%s' (asset: %s) contains %s values outside JavaScript's safe integer range (min: %d, max: %d). Values may not be displayed correctly." + + for _, field := range frame.Fields { + var warning string + + switch field.Type() { + case data.FieldTypeInt64, data.FieldTypeNullableInt64: + var minVal, maxVal int64 + hasUnsafe := false + + for i := 0; i < field.Len(); i++ { + var val int64 + if v, ok := field.At(i).(*int64); ok && v != nil { + val = *v + } else if v, ok := field.At(i).(int64); ok { + val = v + } else { + continue + } + + if i == 0 || val < minVal { + minVal = val + } + if i == 0 || val > maxVal { + maxVal = val + } + if val > jsSafeIntMax || val < jsSafeIntMin { + hasUnsafe = true + } + } + + if hasUnsafe { + assetName := field.Labels["asset"] + if assetName == "" { + assetName = "unknown" + } + warning = fmt.Sprintf(warningFormat, field.Name, assetName, "INT64", minVal, maxVal) + } + + case data.FieldTypeUint64, data.FieldTypeNullableUint64: + var minVal, maxVal uint64 + hasUnsafe := false + + for i := 0; i < field.Len(); i++ { + var val uint64 + if v, ok := field.At(i).(*uint64); ok && v != nil { + val = *v + } else if v, ok := field.At(i).(uint64); ok { + val = v + } else { + continue + } + + if i == 0 || val < minVal { + minVal = val + } + if i == 0 || val > maxVal { + maxVal = val + } + if val > jsSafeUintMax { + hasUnsafe = true + } + } + + if hasUnsafe { + assetName := field.Labels["asset"] + if assetName == "" { + assetName = "unknown" + } + warning = fmt.Sprintf(warningFormat, field.Name, assetName, "UINT64", minVal, maxVal) + } + + default: + // no checking needed + } + + if warning != "" { + if frame.Meta == nil { + frame.Meta = &data.FrameMeta{} + } + frame.Meta.Notices = append(frame.Meta.Notices, data.Notice{ + Severity: data.NoticeSeverityWarning, + Text: warning, + }) + log.DefaultLogger.Warn(warning) + } + } +} + func getChannelQueries(pCtx backend.PluginContext, cdq channelDataQuery, runIds []string, assetIds []string, d *SiftDatasource) ([]siftApiGetDataSubQuery, error) { queries := []siftApiGetDataSubQuery{} if cdq.ChannelQueries == nil { diff --git a/pkg/plugin/datasource_test.go b/pkg/plugin/datasource_test.go index 558ef73..a65b6cd 100644 --- a/pkg/plugin/datasource_test.go +++ b/pkg/plugin/datasource_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/useragent" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -2163,3 +2164,182 @@ func (s *DatasourceTestSuite) TestGenerateDataFrameWithEnumDisplayMixedChannels( // Verify temperature value s.Equal(23.5, *frame.Fields[2].At(0).(*float64)) } + +func TestCheckInt64PrecisionLoss(t *testing.T) { + tests := []struct { + name string + fieldType string + values interface{} + labels map[string]string + expectWarning bool + expectedNotice string + }{ + { + name: "INT64 values within safe range", + fieldType: "int64", + values: []*int64{int64Ptr(100), int64Ptr(200), int64Ptr(-100)}, + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: false, + }, + { + name: "INT64 values exceeding max safe range", + fieldType: "int64", + values: []*int64{int64Ptr(9007199254740992), int64Ptr(100)}, // 2^53 + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: true, + expectedNotice: "Field 'test_field' (asset: TestAsset) contains INT64 values outside JavaScript's safe integer range (min: 100, max: 9007199254740992). Values may not be displayed correctly.", + }, + { + name: "INT64 values below min safe range", + fieldType: "int64", + values: []*int64{int64Ptr(-9007199254740992), int64Ptr(100)}, // -(2^53) + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: true, + expectedNotice: "Field 'test_field' (asset: TestAsset) contains INT64 values outside JavaScript's safe integer range (min: -9007199254740992, max: 100). Values may not be displayed correctly.", + }, + { + name: "INT64 values with both extremes", + fieldType: "int64", + values: []*int64{int64Ptr(-9007199254740992), int64Ptr(9007199254740992)}, + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: true, + expectedNotice: "Field 'test_field' (asset: TestAsset) contains INT64 values outside JavaScript's safe integer range (min: -9007199254740992, max: 9007199254740992). Values may not be displayed correctly.", + }, + { + name: "UINT64 values within safe range", + fieldType: "uint64", + values: []*uint64{uint64Ptr(100), uint64Ptr(200), uint64Ptr(0)}, + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: false, + }, + { + name: "UINT64 values exceeding safe range", + fieldType: "uint64", + values: []*uint64{uint64Ptr(9007199254740992), uint64Ptr(100)}, // 2^53 + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: true, + expectedNotice: "Field 'test_field' (asset: TestAsset) contains UINT64 values outside JavaScript's safe integer range (min: 100, max: 9007199254740992). Values may not be displayed correctly.", + }, + { + name: "INT64 without asset label", + fieldType: "int64", + values: []*int64{int64Ptr(9007199254740992)}, + labels: map[string]string{}, + expectWarning: true, + expectedNotice: "Field 'test_field' (asset: unknown) contains INT64 values outside JavaScript's safe integer range (min: 9007199254740992, max: 9007199254740992). Values may not be displayed correctly.", + }, + { + name: "INT64 at exact safe boundary (max)", + fieldType: "int64", + values: []*int64{int64Ptr(9007199254740991)}, // 2^53 - 1 (safe) + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: false, + }, + { + name: "INT64 at exact safe boundary (min)", + fieldType: "int64", + values: []*int64{int64Ptr(-9007199254740991)}, // -(2^53 - 1) (safe) + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: false, + }, + { + name: "UINT64 at exact safe boundary", + fieldType: "uint64", + values: []*uint64{uint64Ptr(9007199254740991)}, // 2^53 - 1 (safe) + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: false, + }, + { + name: "INT64 with nil values", + fieldType: "int64", + values: []*int64{nil, int64Ptr(100), nil}, + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: false, + }, + { + name: "UINT64 with nil values", + fieldType: "uint64", + values: []*uint64{nil, uint64Ptr(100), nil}, + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: false, + }, + { + name: "Non-nullable INT64 values exceeding safe range", + fieldType: "int64_non_nullable", + values: []int64{9007199254740992, 100}, + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: true, + expectedNotice: "Field 'test_field' (asset: TestAsset) contains INT64 values outside JavaScript's safe integer range (min: 100, max: 9007199254740992). Values may not be displayed correctly.", + }, + { + name: "Non-nullable UINT64 values exceeding safe range", + fieldType: "uint64_non_nullable", + values: []uint64{9007199254740992, 100}, + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: true, + expectedNotice: "Field 'test_field' (asset: TestAsset) contains UINT64 values outside JavaScript's safe integer range (min: 100, max: 9007199254740992). Values may not be displayed correctly.", + }, + { + name: "Non-nullable INT64 values within safe range", + fieldType: "int64_non_nullable", + values: []int64{100, 200, -100}, + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: false, + }, + { + name: "Non-nullable UINT64 values within safe range", + fieldType: "uint64_non_nullable", + values: []uint64{100, 200, 0}, + labels: map[string]string{"asset": "TestAsset"}, + expectWarning: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + frame := createTestFrame(tt.fieldType, tt.values, tt.labels) + + checkInt64PrecisionLoss(frame) + + if tt.expectWarning { + require.NotNil(t, frame.Meta, "Expected frame.Meta to be set") + require.Len(t, frame.Meta.Notices, 1, "Expected exactly one notice") + require.Equal(t, tt.expectedNotice, frame.Meta.Notices[0].Text) + } else { + if frame.Meta != nil { + require.Len(t, frame.Meta.Notices, 0, "Expected no notices") + } + } + }) + } +} + +// Helper functions for tests +func int64Ptr(v int64) *int64 { + return &v +} + +func uint64Ptr(v uint64) *uint64 { + return &v +} + +func createTestFrame(fieldType string, values interface{}, labels map[string]string) *data.Frame { + frame := data.NewFrame("test_frame") + + var field *data.Field + switch fieldType { + case "int64": + field = data.NewField("test_field", labels, values.([]*int64)) + case "uint64": + field = data.NewField("test_field", labels, values.([]*uint64)) + case "int64_non_nullable": + field = data.NewField("test_field", labels, values.([]int64)) + case "uint64_non_nullable": + field = data.NewField("test_field", labels, values.([]uint64)) + default: + panic("unsupported field type") + } + + frame.Fields = append(frame.Fields, field) + return frame +}