diff --git a/README.md b/README.md index 5f3c6c5..c165a1d 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ Asset, Run, and Channel queries are cached to speed up regular expression querie Both caches may be cleared for a panel by clicking the "Clear cache" button next to the Query Mode selector. +#### Annotations + +Annotations are supported and enable querying Sift Annotations or using regular Sift data queries to visualize data, such as enum state changes, as annotations. + ## Learn More - [Sift](https://www.siftstack.com/) diff --git a/jest.config.js b/jest.config.js index 5ae753c..0ceee3c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,9 +2,13 @@ // generally used by snapshots, but can affect specific tests process.env.TZ = 'UTC'; +const { grafanaESModules, nodeModulesToTransform } = require('./.config/jest/utils'); + module.exports = { // Jest configuration provided by Grafana scaffolding ...require('./.config/jest.config'), // Increase default timeout for all tests to 15 seconds testTimeout: 15000, + // Add nanoid and leven to the list of ES modules to transform + transformIgnorePatterns: [nodeModulesToTransform([...grafanaESModules, 'nanoid', 'leven'])], }; diff --git a/pkg/plugin/celUtils/cel_utils.go b/pkg/plugin/celUtils/cel_utils.go index 6751806..0a56ef4 100644 --- a/pkg/plugin/celUtils/cel_utils.go +++ b/pkg/plugin/celUtils/cel_utils.go @@ -86,6 +86,16 @@ func GreaterThan(field string, value float64) string { return fmt.Sprintf("%s > %v", field, value) } +// GreaterThanOrEqual generates a CEL expression that checks whether a field is greater than or equal to a given string value. +func GreaterThanOrEqual(field string, value string) string { + return fmt.Sprintf(`%s >= %s`, field, value) +} + +// LessThanOrEqual generates a CEL expression that checks whether a field is less than or equal to a given string value. +func LessThanOrEqual(field string, value string) string { + return fmt.Sprintf(`%s <= %s`, field, value) +} + // Contains generates a CEL expression that checks whether a string field contains a given value. func Contains(field, value string) string { return fmt.Sprintf(`%s.contains('%s')`, field, value) diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index 955c3f4..6412ba2 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -40,10 +40,11 @@ const QueryVersion = "2.1" const maxParallelDataQueries = 10 const ( - EnumDisplayNone = "" - EnumDisplayBoth = "both" - EnumDisplayValue = "value" - EnumDisplayString = "string" + EnumDisplayNone = "" + EnumDisplayBoth = "both" + EnumDisplayValue = "value" + EnumDisplayString = "string" + EnumDisplayCombined = "combined" ) var ValidSiftGrafanaDataTypes = []string{ @@ -250,6 +251,8 @@ type queryModel struct { CombineRuns bool `json:"combineRuns"` EnumDisplay string `json:"enumDisplay"` QueryVersion string `json:"queryVersion"` + AnnotationType string `json:"annotationType"` + AnnotationFilter string `json:"annotationFilter"` } type queryResponse struct { @@ -352,6 +355,7 @@ type frameKey struct { runId string bitFieldElementName string isEnumString bool + isEnumCombined bool } type expressionChannelReference struct { @@ -390,6 +394,12 @@ func (d *SiftDatasource) query(pCtx backend.PluginContext, query backend.DataQue log.DefaultLogger.Error("recovered from panic", "error", err) } }() + + // Route annotationsQuery to the Sift annotations API + if fqm.AnnotationType == "annotationsQuery" { + return d.querySiftAnnotations(pCtx, query, fqm) + } + var response backend.DataResponse queryStart := time.Now() @@ -405,7 +415,13 @@ func (d *SiftDatasource) query(pCtx backend.PluginContext, query backend.DataQue } afterExecutingQueries := time.Now() - frame, err := generateDataFrame(responseData, calculatedChannelKeys, fqm.CombineRuns, fqm.EnumDisplay) + var frame *data.Frame + if fqm.AnnotationType != "" { + // Any annotationType set means we want the flat annotation frame format + frame, err = generateAnnotationFrame(responseData, calculatedChannelKeys, fqm.CombineRuns, fqm.EnumDisplay) + } else { + frame, err = generateDataFrame(responseData, calculatedChannelKeys, fqm.CombineRuns, fqm.EnumDisplay) + } if err != nil { return backend.ErrDataResponse(backend.StatusBadRequest, fmt.Sprintf("error generating data frame: %v", err.Error())) } @@ -583,10 +599,10 @@ func generateDataFrame(responseData []queryResponseData, calculatedChannelKeys m } } case "CHANNEL_DATA_TYPE_ENUM": - for _, v := range []bool{true, false} { + if enumDisplay == EnumDisplayCombined { key := frameKey{ - channelId: d.Metadata.Channel.ChannelId, - isEnumString: v, + channelId: d.Metadata.Channel.ChannelId, + isEnumCombined: true, } if !combineRuns { key.runId = d.Metadata.Run.RunId @@ -595,6 +611,20 @@ func generateDataFrame(responseData []queryResponseData, calculatedChannelKeys m if _, ok := md[key]; !ok { md[key] = d.Metadata } + } else { + for _, v := range []bool{true, false} { + key := frameKey{ + channelId: d.Metadata.Channel.ChannelId, + isEnumString: v, + } + if !combineRuns { + key.runId = d.Metadata.Run.RunId + } + dataMap[key] = append(dataMap[key], d) + if _, ok := md[key]; !ok { + md[key] = d.Metadata + } + } } default: key := frameKey{ @@ -737,7 +767,14 @@ func generateDataFrame(responseData []queryResponseData, calculatedChannelKeys m } for _, vv := range v { - if key.isEnumString { + if key.isEnumCombined { + enumName, ok := enumLookup[vv.Value] + if ok { + values[vv.Timestamp.UnixNano()] = fmt.Sprintf("[%d] %s", vv.Value, enumName) + } else { + values[vv.Timestamp.UnixNano()] = fmt.Sprintf("%d", vv.Value) + } + } else if key.isEnumString { if enumName, ok := enumLookup[vv.Value]; ok { values[vv.Timestamp.UnixNano()] = enumName } else { @@ -859,14 +896,18 @@ func generateDataFrame(responseData []queryResponseData, calculatedChannelKeys m field = data.NewField(name, labels, []*uint64{}) case "CHANNEL_DATA_TYPE_ENUM": - // Track the base name for this enum field - enumFieldBaseNames[name] = true - if key.isEnumString { - name = name + "_string" + if key.isEnumCombined { field = data.NewField(name, labels, []*string{}) } else { - name = name + "_value" - field = data.NewField(name, labels, []*uint32{}) + // Track the base name for this enum field + enumFieldBaseNames[name] = true + if key.isEnumString { + name = name + "_string" + field = data.NewField(name, labels, []*string{}) + } else { + name = name + "_value" + field = data.NewField(name, labels, []*uint32{}) + } } case "CHANNEL_DATA_TYPE_BIT_FIELD": @@ -954,6 +995,309 @@ func generateDataFrame(responseData []queryResponseData, calculatedChannelKeys m return frame, nil } +// generateAnnotationFrame creates an annotation-compatible data frame by reusing generateDataFrame +// and converting it to a flat row-per-event format with metadata columns. +func generateAnnotationFrame(responseData []queryResponseData, calculatedChannelKeys map[string]calculatedChannelKey, combineRuns bool, enumDisplay string) (*data.Frame, error) { + // Use combined mode for enums so we get a single "string (number)" field + annotationEnumDisplay := enumDisplay + if annotationEnumDisplay == "" || annotationEnumDisplay == EnumDisplayBoth { + annotationEnumDisplay = EnumDisplayCombined + } + sourceFrame, err := generateDataFrame(responseData, calculatedChannelKeys, combineRuns, annotationEnumDisplay) + if err != nil { + return nil, err + } + + // Find the time field + var timeField *data.Field + for _, f := range sourceFrame.Fields { + if f.Type() == data.FieldTypeTime { + timeField = f + break + } + } + if timeField == nil { + return nil, fmt.Errorf("no time field found in source frame") + } + + // Collect annotation entries from all value fields + type annotationEntry struct { + timestamp time.Time + value string + channelId string + channelName string + assetId string + assetName string + runId string + runName string + } + var entries []annotationEntry + + // Track which metadata fields have values + hasChannelId := false + hasAssetId := false + hasAssetName := false + hasRunId := false + hasRunName := false + + for _, field := range sourceFrame.Fields { + if field.Type() == data.FieldTypeTime { + continue + } + + // Extract metadata from labels + labels := field.Labels + channelName := field.Name + channelId := labels["channel_id"] + assetName := labels["asset"] + assetId := labels["asset_id"] + runName := labels["run"] + runId := labels["run_id"] + + // Track which fields exist + if channelId != "" { + hasChannelId = true + } + if assetId != "" { + hasAssetId = true + } + if assetName != "" { + hasAssetName = true + } + if runId != "" { + hasRunId = true + } + if runName != "" { + hasRunName = true + } + + // Iterate through all rows + for i := 0; i < field.Len(); i++ { + val := field.At(i) + if val == nil { + continue + } + + t := timeField.At(i).(time.Time) + + // Convert value to string + var valueStr string + switch v := val.(type) { + case *string: + if v != nil { + valueStr = *v + } + case *bool: + if v != nil { + valueStr = strconv.FormatBool(*v) + } + case *float64: + if v != nil { + valueStr = strconv.FormatFloat(*v, 'f', -1, 64) + } + case *float32: + if v != nil { + valueStr = strconv.FormatFloat(float64(*v), 'f', -1, 32) + } + case *int64: + if v != nil { + valueStr = strconv.FormatInt(*v, 10) + } + case *int32: + if v != nil { + valueStr = strconv.FormatInt(int64(*v), 10) + } + case *uint64: + if v != nil { + valueStr = strconv.FormatUint(*v, 10) + } + case *uint32: + if v != nil { + valueStr = strconv.FormatUint(uint64(*v), 10) + } + default: + valueStr = fmt.Sprintf("%v", val) + } + + entries = append(entries, annotationEntry{ + timestamp: t, + value: valueStr, + channelId: channelId, + channelName: channelName, + assetId: assetId, + assetName: assetName, + runId: runId, + runName: runName, + }) + } + } + + // Sort by timestamp + sort.Slice(entries, func(i, j int) bool { + return entries[i].timestamp.Before(entries[j].timestamp) + }) + + // Build annotation frame + n := len(entries) + times := make([]time.Time, n) + values := make([]string, n) + channelNames := make([]string, n) + + for i, e := range entries { + times[i] = e.timestamp + values[i] = e.value + channelNames[i] = e.channelName + } + + frame := data.NewFrame("annotations", + data.NewField("time", nil, times), + data.NewField("value", nil, values), + data.NewField("channelName", nil, channelNames), + ) + + // Conditionally add metadata fields + if hasChannelId { + channelIds := make([]string, n) + for i, e := range entries { + channelIds[i] = e.channelId + } + frame.Fields = append(frame.Fields, data.NewField("channelId", nil, channelIds)) + } + if hasAssetName { + assetNames := make([]string, n) + for i, e := range entries { + assetNames[i] = e.assetName + } + frame.Fields = append(frame.Fields, data.NewField("assetName", nil, assetNames)) + } + if hasAssetId { + assetIds := make([]string, n) + for i, e := range entries { + assetIds[i] = e.assetId + } + frame.Fields = append(frame.Fields, data.NewField("assetId", nil, assetIds)) + } + if hasRunName { + runNames := make([]string, n) + for i, e := range entries { + runNames[i] = e.runName + } + frame.Fields = append(frame.Fields, data.NewField("runName", nil, runNames)) + } + if hasRunId { + runIds := make([]string, n) + for i, e := range entries { + runIds[i] = e.runId + } + frame.Fields = append(frame.Fields, data.NewField("runId", nil, runIds)) + } + + return frame, nil +} + +// querySiftAnnotations handles the annotationsQuery type by calling the Sift annotations API +// and converting the response into a Grafana-compatible annotation data frame. +func (d *SiftDatasource) querySiftAnnotations(pCtx backend.PluginContext, query backend.DataQuery, fqm queryModel) backend.DataResponse { + var response backend.DataResponse + + annotations, err := d.listSiftAnnotations(pCtx, query, fqm.AnnotationFilter) + if err != nil { + return backend.ErrDataResponse(backend.StatusBadRequest, fmt.Sprintf("error listing Sift annotations: %v", err.Error())) + } + + frame, err := generateSiftAnnotationsFrame(annotations) + if err != nil { + return backend.ErrDataResponse(backend.StatusBadRequest, fmt.Sprintf("error generating Sift annotations frame: %v", err.Error())) + } + + response.Frames = append(response.Frames, frame) + return response +} + +// generateSiftAnnotationsFrame converts a slice of SiftAnnotation into a Grafana data frame. +func generateSiftAnnotationsFrame(annotations []SiftAnnotation) (*data.Frame, error) { + n := len(annotations) + + startTimes := make([]time.Time, n) + endTimes := make([]*time.Time, n) + names := make([]string, n) + descriptions := make([]string, n) + annotationIds := make([]string, n) + annotationTypes := make([]string, n) + tags := make([]string, n) + states := make([]string, n) + + // Track which optional fields have values + hasRunId := false + hasAssetIds := false + + for i, a := range annotations { + // Parse start time + t, err := time.Parse(time.RFC3339Nano, a.StartTime) + if err != nil { + return nil, fmt.Errorf("error parsing start_time for annotation %s: %w", a.AnnotationId, err) + } + startTimes[i] = t + + // Parse end time (optional) + if a.EndTime != "" { + et, err := time.Parse(time.RFC3339Nano, a.EndTime) + if err != nil { + return nil, fmt.Errorf("error parsing end_time for annotation %s: %w", a.AnnotationId, err) + } + endTimes[i] = &et + } + + names[i] = a.Name + descriptions[i] = a.Description + annotationIds[i] = a.AnnotationId + annotationTypes[i] = a.AnnotationType + states[i] = a.State + + if len(a.Tags) > 0 { + tags[i] = strings.Join(a.Tags, ", ") + } + + if a.RunId != "" { + hasRunId = true + } + if len(a.AssetIds) > 0 { + hasAssetIds = true + } + } + + frame := data.NewFrame("annotations", + data.NewField("time", nil, startTimes), + data.NewField("timeEnd", nil, endTimes), + data.NewField("title", nil, names), + data.NewField("text", nil, descriptions), + data.NewField("tags", nil, tags), + data.NewField("annotationId", nil, annotationIds), + data.NewField("annotationType", nil, annotationTypes), + data.NewField("state", nil, states), + ) + + // Conditionally add optional fields + if hasRunId { + runIds := make([]string, n) + for i, a := range annotations { + runIds[i] = a.RunId + } + frame.Fields = append(frame.Fields, data.NewField("runId", nil, runIds)) + } + if hasAssetIds { + assetIds := make([]string, n) + for i, a := range annotations { + if len(a.AssetIds) > 0 { + assetIds[i] = strings.Join(a.AssetIds, ", ") + } + } + frame.Fields = append(frame.Fields, data.NewField("assetIds", nil, assetIds)) + } + + 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) { diff --git a/pkg/plugin/datasource_test.go b/pkg/plugin/datasource_test.go index a65b6cd..bd70d94 100644 --- a/pkg/plugin/datasource_test.go +++ b/pkg/plugin/datasource_test.go @@ -2314,6 +2314,562 @@ func TestCheckInt64PrecisionLoss(t *testing.T) { } } +// TestGenerateSiftAnnotationsFrame tests the generateSiftAnnotationsFrame function +func (s *DatasourceTestSuite) TestGenerateSiftAnnotationsFrameBasic() { + annotations := []SiftAnnotation{ + { + AnnotationId: "ann1", + Name: "Phase Start", + Description: "Engine ignition phase started", + StartTime: "2025-01-15T10:00:00Z", + EndTime: "2025-01-15T10:05:00Z", + CreatedDate: "2025-01-15T09:00:00Z", + ModifiedDate: "2025-01-15T09:00:00Z", + OrganizationId: "org1", + AnnotationType: "ANNOTATION_TYPE_PHASE", + Tags: []string{"engine", "ignition"}, + State: "ANNOTATION_STATE_OPEN", + RunId: "run1", + AssetIds: []string{"asset1", "asset2"}, + }, + { + AnnotationId: "ann2", + Name: "Anomaly Detected", + Description: "Temperature spike observed", + StartTime: "2025-01-15T10:02:00Z", + EndTime: "", + CreatedDate: "2025-01-15T10:02:00Z", + ModifiedDate: "2025-01-15T10:02:00Z", + OrganizationId: "org1", + AnnotationType: "ANNOTATION_TYPE_POINT", + Tags: []string{}, + State: "ANNOTATION_STATE_OPEN", + RunId: "run1", + AssetIds: []string{"asset1"}, + }, + } + + frame, err := generateSiftAnnotationsFrame(annotations) + s.NoError(err) + s.NotNil(frame) + s.Equal("annotations", frame.Name) + + // Base fields: time, timeEnd, title, text, tags, annotationId, annotationType, state + // Optional fields: runId, assetIds (both present) + s.Equal(10, len(frame.Fields)) + + // Verify field names + s.Equal("time", frame.Fields[0].Name) + s.Equal("timeEnd", frame.Fields[1].Name) + s.Equal("title", frame.Fields[2].Name) + s.Equal("text", frame.Fields[3].Name) + s.Equal("tags", frame.Fields[4].Name) + s.Equal("annotationId", frame.Fields[5].Name) + s.Equal("annotationType", frame.Fields[6].Name) + s.Equal("state", frame.Fields[7].Name) + s.Equal("runId", frame.Fields[8].Name) + s.Equal("assetIds", frame.Fields[9].Name) + + // Verify row count + s.Equal(2, frame.Fields[0].Len()) + + // Verify first annotation values + startTime1, _ := time.Parse(time.RFC3339Nano, "2025-01-15T10:00:00Z") + endTime1, _ := time.Parse(time.RFC3339Nano, "2025-01-15T10:05:00Z") + s.Equal(startTime1, frame.Fields[0].At(0)) + s.Equal(&endTime1, frame.Fields[1].At(0)) + s.Equal("Phase Start", frame.Fields[2].At(0)) + s.Equal("Engine ignition phase started", frame.Fields[3].At(0)) + s.Equal("engine, ignition", frame.Fields[4].At(0)) + s.Equal("ann1", frame.Fields[5].At(0)) + s.Equal("ANNOTATION_TYPE_PHASE", frame.Fields[6].At(0)) + s.Equal("ANNOTATION_STATE_OPEN", frame.Fields[7].At(0)) + s.Equal("run1", frame.Fields[8].At(0)) + s.Equal("asset1, asset2", frame.Fields[9].At(0)) + + // Verify second annotation - no end time + startTime2, _ := time.Parse(time.RFC3339Nano, "2025-01-15T10:02:00Z") + s.Equal(startTime2, frame.Fields[0].At(1)) + s.Nil(frame.Fields[1].At(1).(*time.Time)) + s.Equal("Anomaly Detected", frame.Fields[2].At(1)) + s.Equal("Temperature spike observed", frame.Fields[3].At(1)) + s.Equal("", frame.Fields[4].At(1)) + s.Equal("asset1", frame.Fields[9].At(1)) +} + +func (s *DatasourceTestSuite) TestGenerateSiftAnnotationsFrameEmpty() { + frame, err := generateSiftAnnotationsFrame([]SiftAnnotation{}) + s.NoError(err) + s.NotNil(frame) + s.Equal("annotations", frame.Name) + + // Should have 8 base fields, no optional fields + s.Equal(8, len(frame.Fields)) + s.Equal(0, frame.Fields[0].Len()) +} + +func (s *DatasourceTestSuite) TestGenerateSiftAnnotationsFrameNoOptionalFields() { + annotations := []SiftAnnotation{ + { + AnnotationId: "ann1", + Name: "Simple Annotation", + Description: "No run or assets", + StartTime: "2025-01-15T10:00:00Z", + OrganizationId: "org1", + AnnotationType: "ANNOTATION_TYPE_POINT", + State: "ANNOTATION_STATE_OPEN", + }, + } + + frame, err := generateSiftAnnotationsFrame(annotations) + s.NoError(err) + s.NotNil(frame) + + // Should only have 8 base fields (no runId or assetIds) + s.Equal(8, len(frame.Fields)) + + fieldNames := make([]string, len(frame.Fields)) + for i, f := range frame.Fields { + fieldNames[i] = f.Name + } + s.NotContains(fieldNames, "runId") + s.NotContains(fieldNames, "assetIds") +} + +func (s *DatasourceTestSuite) TestGenerateSiftAnnotationsFrameInvalidStartTime() { + annotations := []SiftAnnotation{ + { + AnnotationId: "ann1", + Name: "Bad Time", + StartTime: "not-a-timestamp", + }, + } + + frame, err := generateSiftAnnotationsFrame(annotations) + s.Nil(frame) + s.Error(err) + s.Contains(err.Error(), "error parsing start_time for annotation ann1") +} + +func (s *DatasourceTestSuite) TestGenerateSiftAnnotationsFrameInvalidEndTime() { + annotations := []SiftAnnotation{ + { + AnnotationId: "ann1", + Name: "Bad End Time", + StartTime: "2025-01-15T10:00:00Z", + EndTime: "not-a-timestamp", + }, + } + + frame, err := generateSiftAnnotationsFrame(annotations) + s.Nil(frame) + s.Error(err) + s.Contains(err.Error(), "error parsing end_time for annotation ann1") +} + +func (s *DatasourceTestSuite) TestGenerateSiftAnnotationsFrameMultipleTags() { + annotations := []SiftAnnotation{ + { + AnnotationId: "ann1", + Name: "Tagged", + StartTime: "2025-01-15T10:00:00Z", + OrganizationId: "org1", + AnnotationType: "ANNOTATION_TYPE_PHASE", + Tags: []string{"tag1", "tag2", "tag3"}, + }, + } + + frame, err := generateSiftAnnotationsFrame(annotations) + s.NoError(err) + s.Equal("tag1, tag2, tag3", frame.Fields[4].At(0)) +} + +func (s *DatasourceTestSuite) TestGenerateAnnotationFrameBasicDouble() { + now := time.Now() + responseData := []queryResponseData{ + { + Metadata: queryResponseMetadata{ + DataType: "CHANNEL_DATA_TYPE_DOUBLE", + Asset: struct { + AssetId string "json:\"assetId\"" + Name string "json:\"name\"" + }{ + AssetId: "asset1", + Name: "Asset 1", + }, + Run: struct { + RunId string "json:\"runId\"" + Name string "json:\"name\"" + }{ + RunId: "run1", + Name: "Run 1", + }, + Channel: struct { + ChannelId string "json:\"channelId\"" + Name string "json:\"name\"" + EnumTypes []queryResponseChannelEnumType "json:\"enumTypes\"" + BitFieldElements []queryResponseChannelBitFieldElement "json:\"bitFieldElements\"" + }{ + ChannelId: "channel1", + Name: "Temperature", + }, + }, + Values: json.RawMessage(`[ + {"timestamp": "` + now.Format(time.RFC3339Nano) + `", "value": 23.5}, + {"timestamp": "` + now.Add(time.Second).Format(time.RFC3339Nano) + `", "value": 24.1} + ]`), + }, + } + + frame, err := generateAnnotationFrame(responseData, nil, false, EnumDisplayBoth) + s.NoError(err) + s.NotNil(frame) + s.Equal("annotations", frame.Name) + + // Base fields: time, value, channelName + conditional: channelId, assetName, assetId, runName, runId + s.Equal(8, len(frame.Fields)) + s.Equal("time", frame.Fields[0].Name) + s.Equal("value", frame.Fields[1].Name) + s.Equal("channelName", frame.Fields[2].Name) + s.Equal("channelId", frame.Fields[3].Name) + s.Equal("assetName", frame.Fields[4].Name) + s.Equal("assetId", frame.Fields[5].Name) + s.Equal("runName", frame.Fields[6].Name) + s.Equal("runId", frame.Fields[7].Name) + + // Verify row count + s.Equal(2, frame.Fields[0].Len()) + + // Verify values are converted to strings + s.Equal("23.5", frame.Fields[1].At(0)) + s.Equal("24.1", frame.Fields[1].At(1)) + + // Verify channel name + s.Equal("Temperature", frame.Fields[2].At(0)) + s.Equal("Temperature", frame.Fields[2].At(1)) + + // Verify metadata + s.Equal("channel1", frame.Fields[3].At(0)) + s.Equal("Asset 1", frame.Fields[4].At(0)) + s.Equal("asset1", frame.Fields[5].At(0)) + s.Equal("Run 1", frame.Fields[6].At(0)) + s.Equal("run1", frame.Fields[7].At(0)) +} + +func (s *DatasourceTestSuite) TestGenerateAnnotationFrameMultipleChannelsSameTimestamp() { + now := time.Now() + responseData := []queryResponseData{ + { + Metadata: queryResponseMetadata{ + DataType: "CHANNEL_DATA_TYPE_DOUBLE", + Asset: struct { + AssetId string "json:\"assetId\"" + Name string "json:\"name\"" + }{ + AssetId: "asset1", + Name: "Asset 1", + }, + Channel: struct { + ChannelId string "json:\"channelId\"" + Name string "json:\"name\"" + EnumTypes []queryResponseChannelEnumType "json:\"enumTypes\"" + BitFieldElements []queryResponseChannelBitFieldElement "json:\"bitFieldElements\"" + }{ + ChannelId: "channel1", + Name: "Temperature", + }, + }, + Values: json.RawMessage(`[ + {"timestamp": "` + now.Format(time.RFC3339Nano) + `", "value": 23.5} + ]`), + }, + { + Metadata: queryResponseMetadata{ + DataType: "CHANNEL_DATA_TYPE_INT_32", + Asset: struct { + AssetId string "json:\"assetId\"" + Name string "json:\"name\"" + }{ + AssetId: "asset1", + Name: "Asset 1", + }, + Channel: struct { + ChannelId string "json:\"channelId\"" + Name string "json:\"name\"" + EnumTypes []queryResponseChannelEnumType "json:\"enumTypes\"" + BitFieldElements []queryResponseChannelBitFieldElement "json:\"bitFieldElements\"" + }{ + ChannelId: "channel2", + Name: "Pressure", + }, + }, + Values: json.RawMessage(`[ + {"timestamp": "` + now.Format(time.RFC3339Nano) + `", "value": 100} + ]`), + }, + } + + frame, err := generateAnnotationFrame(responseData, nil, false, EnumDisplayBoth) + s.NoError(err) + s.NotNil(frame) + + // Both channels share the same timestamp, so the wide frame has 1 row with 2 value columns. + // generateAnnotationFrame flattens each value field into separate entries. + // Verify we get entries for both channels. + rowCount := frame.Fields[0].Len() + s.GreaterOrEqual(rowCount, 2) + + // Collect all channel names from the frame + channelNames := make(map[string]bool) + for i := 0; i < frame.Fields[2].Len(); i++ { + channelNames[frame.Fields[2].At(i).(string)] = true + } + s.True(channelNames["Temperature"]) + s.True(channelNames["Pressure"]) +} + +func (s *DatasourceTestSuite) TestGenerateAnnotationFrameEnumUsesCombinedMode() { + now := time.Now() + responseData := []queryResponseData{ + { + Metadata: queryResponseMetadata{ + DataType: "CHANNEL_DATA_TYPE_ENUM", + Asset: struct { + AssetId string "json:\"assetId\"" + Name string "json:\"name\"" + }{ + AssetId: "asset1", + Name: "Asset 1", + }, + Channel: struct { + ChannelId string "json:\"channelId\"" + Name string "json:\"name\"" + EnumTypes []queryResponseChannelEnumType "json:\"enumTypes\"" + BitFieldElements []queryResponseChannelBitFieldElement "json:\"bitFieldElements\"" + }{ + ChannelId: "channel1", + Name: "Status", + EnumTypes: []queryResponseChannelEnumType{ + {Name: "ON", Key: 1}, + {Name: "OFF", Key: 2}, + }, + }, + }, + Values: json.RawMessage(`[ + {"timestamp": "` + now.Format(time.RFC3339Nano) + `", "value": 1}, + {"timestamp": "` + now.Add(time.Second).Format(time.RFC3339Nano) + `", "value": 2} + ]`), + }, + } + + // When enumDisplay is "both", generateAnnotationFrame should override to combined + frame, err := generateAnnotationFrame(responseData, nil, false, EnumDisplayBoth) + s.NoError(err) + s.NotNil(frame) + + // Should produce a single combined field per enum channel, not two + s.Equal(2, frame.Fields[0].Len()) + + // Channel name should be base name (no _string/_value suffix) + s.Equal("Status", frame.Fields[2].At(0)) + + // Values should be combined format "[number] string" + s.Equal("[1] ON", frame.Fields[1].At(0)) + s.Equal("[2] OFF", frame.Fields[1].At(1)) +} + +func (s *DatasourceTestSuite) TestGenerateAnnotationFrameNoMetadata() { + now := time.Now() + responseData := []queryResponseData{ + { + Metadata: queryResponseMetadata{ + DataType: "CHANNEL_DATA_TYPE_DOUBLE", + Channel: struct { + ChannelId string "json:\"channelId\"" + Name string "json:\"name\"" + EnumTypes []queryResponseChannelEnumType "json:\"enumTypes\"" + BitFieldElements []queryResponseChannelBitFieldElement "json:\"bitFieldElements\"" + }{ + Name: "Temperature", + }, + }, + Values: json.RawMessage(`[ + {"timestamp": "` + now.Format(time.RFC3339Nano) + `", "value": 23.5} + ]`), + }, + } + + frame, err := generateAnnotationFrame(responseData, nil, false, "") + s.NoError(err) + s.NotNil(frame) + + // Should only have 3 base fields (time, value, channelName) - no optional metadata + s.Equal(3, len(frame.Fields)) + s.Equal("time", frame.Fields[0].Name) + s.Equal("value", frame.Fields[1].Name) + s.Equal("channelName", frame.Fields[2].Name) +} + +func (s *DatasourceTestSuite) TestGenerateAnnotationFrameHandlesNullValues() { + now := time.Now() + responseData := []queryResponseData{ + { + Metadata: queryResponseMetadata{ + DataType: "CHANNEL_DATA_TYPE_DOUBLE", + Channel: struct { + ChannelId string "json:\"channelId\"" + Name string "json:\"name\"" + EnumTypes []queryResponseChannelEnumType "json:\"enumTypes\"" + BitFieldElements []queryResponseChannelBitFieldElement "json:\"bitFieldElements\"" + }{ + Name: "Temperature", + }, + }, + Values: json.RawMessage(`[ + {"timestamp": "` + now.Format(time.RFC3339Nano) + `", "value": 23.5}, + {"timestamp": "` + now.Add(time.Second).Format(time.RFC3339Nano) + `", "value": null}, + {"timestamp": "` + now.Add(2*time.Second).Format(time.RFC3339Nano) + `", "value": 25.0} + ]`), + }, + } + + frame, err := generateAnnotationFrame(responseData, nil, false, "") + s.NoError(err) + s.NotNil(frame) + + // All 3 rows are present. Null JSON values are deserialized as zero values + // by generateDataFrame, so they appear as "0" in the annotation frame. + s.Equal(3, frame.Fields[0].Len()) + s.Equal("23.5", frame.Fields[1].At(0)) + s.Equal("0", frame.Fields[1].At(1)) + s.Equal("25", frame.Fields[1].At(2)) +} + +func (s *DatasourceTestSuite) TestGenerateAnnotationFrameBoolValues() { + now := time.Now() + responseData := []queryResponseData{ + { + Metadata: queryResponseMetadata{ + DataType: "CHANNEL_DATA_TYPE_BOOL", + Channel: struct { + ChannelId string "json:\"channelId\"" + Name string "json:\"name\"" + EnumTypes []queryResponseChannelEnumType "json:\"enumTypes\"" + BitFieldElements []queryResponseChannelBitFieldElement "json:\"bitFieldElements\"" + }{ + Name: "Switch", + }, + }, + Values: json.RawMessage(`[ + {"timestamp": "` + now.Format(time.RFC3339Nano) + `", "value": true}, + {"timestamp": "` + now.Add(time.Second).Format(time.RFC3339Nano) + `", "value": false} + ]`), + }, + } + + frame, err := generateAnnotationFrame(responseData, nil, false, "") + s.NoError(err) + s.NotNil(frame) + + s.Equal(2, frame.Fields[0].Len()) + s.Equal("true", frame.Fields[1].At(0)) + s.Equal("false", frame.Fields[1].At(1)) +} + +func (s *DatasourceTestSuite) TestGenerateDataFrameEnumCombinedMode() { + now := time.Now() + responseData := []queryResponseData{ + { + Metadata: queryResponseMetadata{ + DataType: "CHANNEL_DATA_TYPE_ENUM", + Asset: struct { + AssetId string "json:\"assetId\"" + Name string "json:\"name\"" + }{ + AssetId: "asset1", + Name: "Asset 1", + }, + Channel: struct { + ChannelId string "json:\"channelId\"" + Name string "json:\"name\"" + EnumTypes []queryResponseChannelEnumType "json:\"enumTypes\"" + BitFieldElements []queryResponseChannelBitFieldElement "json:\"bitFieldElements\"" + }{ + ChannelId: "channel1", + Name: "Status", + EnumTypes: []queryResponseChannelEnumType{ + {Name: "ON", Key: 1}, + {Name: "OFF", Key: 2}, + {Name: "STANDBY", Key: 3}, + }, + }, + }, + Values: json.RawMessage(`[ + {"timestamp": "` + now.Format(time.RFC3339Nano) + `", "value": 1}, + {"timestamp": "` + now.Add(time.Second).Format(time.RFC3339Nano) + `", "value": 2}, + {"timestamp": "` + now.Add(2*time.Second).Format(time.RFC3339Nano) + `", "value": 3} + ]`), + }, + } + + frame, err := generateDataFrame(responseData, nil, false, EnumDisplayCombined) + s.NoError(err) + + // Combined mode should produce a single field (plus time), not two + s.Equal(2, len(frame.Fields)) + s.Equal("time", frame.Fields[0].Name) + s.Equal("Status", frame.Fields[1].Name) + + // Verify combined values are "[number] string" format + s.Equal("[1] ON", *frame.Fields[1].At(0).(*string)) + s.Equal("[2] OFF", *frame.Fields[1].At(1).(*string)) + s.Equal("[3] STANDBY", *frame.Fields[1].At(2).(*string)) +} + +func (s *DatasourceTestSuite) TestGenerateDataFrameEnumCombinedVsBothMode() { + now := time.Now() + responseData := []queryResponseData{ + { + Metadata: queryResponseMetadata{ + DataType: "CHANNEL_DATA_TYPE_ENUM", + Asset: struct { + AssetId string "json:\"assetId\"" + Name string "json:\"name\"" + }{ + AssetId: "asset1", + Name: "Asset 1", + }, + Channel: struct { + ChannelId string "json:\"channelId\"" + Name string "json:\"name\"" + EnumTypes []queryResponseChannelEnumType "json:\"enumTypes\"" + BitFieldElements []queryResponseChannelBitFieldElement "json:\"bitFieldElements\"" + }{ + ChannelId: "channel1", + Name: "Status", + EnumTypes: []queryResponseChannelEnumType{ + {Name: "ON", Key: 1}, + {Name: "OFF", Key: 2}, + }, + }, + }, + Values: json.RawMessage(`[ + {"timestamp": "` + now.Format(time.RFC3339Nano) + `", "value": 1} + ]`), + }, + } + + // "both" mode should produce two fields (string + value) + frameBoth, err := generateDataFrame(responseData, nil, false, EnumDisplayBoth) + s.NoError(err) + s.Equal(3, len(frameBoth.Fields)) // time + Status_string + Status_value + + // "combined" mode should produce one field + frameCombined, err := generateDataFrame(responseData, nil, false, EnumDisplayCombined) + s.NoError(err) + s.Equal(2, len(frameCombined.Fields)) // time + Status +} + // Helper functions for tests func int64Ptr(v int64) *int64 { return &v diff --git a/pkg/plugin/sift_api_queries.go b/pkg/plugin/sift_api_queries.go index a30e33d..04fd8b4 100644 --- a/pkg/plugin/sift_api_queries.go +++ b/pkg/plugin/sift_api_queries.go @@ -59,6 +59,29 @@ type listChannelsQueryResponse struct { NextPageToken string `json:"nextPageToken"` } +type SiftAnnotation struct { + AnnotationId string `json:"annotationId"` + Name string `json:"name"` + Description string `json:"description"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + CreatedDate string `json:"createdDate"` + ModifiedDate string `json:"modifiedDate"` + RunId string `json:"runId,omitempty"` + OrganizationId string `json:"organizationId"` + AnnotationType string `json:"annotationType"` + Tags []string `json:"tags"` + State string `json:"state,omitempty"` + Pending bool `json:"pending"` + AssetIds []string `json:"assetIds"` + IsArchived bool `json:"isArchived"` +} + +type listSiftAnnotationsResponse struct { + Annotations []SiftAnnotation `json:"annotations"` + NextPageToken string `json:"nextPageToken"` +} + type apiRequest struct { pCtx backend.PluginContext method string @@ -203,6 +226,40 @@ func handlePaginatedRequest[T any]( return results, nil } +func (d *SiftDatasource) listSiftAnnotations(pCtx backend.PluginContext, query backend.DataQuery, filter string) ([]SiftAnnotation, error) { + params := url.Values{} + + // Build time filter using CEL + startTimeFilter := celUtils.GreaterThanOrEqual("start_time", fmt.Sprintf("timestamp('%s')", query.TimeRange.From.Format(time.RFC3339Nano))) + endTimeFilter := celUtils.LessThanOrEqual("start_time", fmt.Sprintf("timestamp('%s')", query.TimeRange.To.Format(time.RFC3339Nano))) + timeFilter := celUtils.And(startTimeFilter, endTimeFilter) + + if filter != "" { + params.Set("filter", celUtils.And(timeFilter, filter)) + } else { + params.Set("filter", timeFilter) + } + + annotations, err := handlePaginatedRequest[SiftAnnotation](d, apiRequest{ + pCtx: pCtx, + method: "GET", + path: "/api/v1/annotations", + queryParams: params, + }, 1000, 10, func(respBody []byte) ([]SiftAnnotation, string, error) { + response := listSiftAnnotationsResponse{} + err := json.Unmarshal(respBody, &response) + if err != nil { + return nil, "", fmt.Errorf("json unmarshal: %w", err) + } + return response.Annotations, response.NextPageToken, nil + }) + if err != nil { + return nil, err + } + + return annotations, nil +} + func (d *SiftDatasource) getData(pCtx backend.PluginContext, subQueries []siftApiGetDataSubQuery, query backend.DataQuery) ([]queryResponseData, error) { backendQuery := siftApiGetDataQuery{ Queries: subQueries, diff --git a/provisioning/dashboards/json/sample-dashboard.json b/provisioning/dashboards/json/sample-dashboard.json index bb5ed60..df3a4c3 100644 --- a/provisioning/dashboards/json/sample-dashboard.json +++ b/provisioning/dashboards/json/sample-dashboard.json @@ -12,6 +12,101 @@ "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" + }, + { + "datasource": { + "type": "sift-grafana-datasource", + "uid": "P5B56D21E16392E86" + }, + "enable": true, + "hide": false, + "iconColor": "text", + "name": "Vehicle State Changes", + "target": { + "annotationType": "dataQuery", + "channelDataQueries": [ + { + "assetQueries": [ + { + "asSelect": true, + "assetId": "60a0e291-9ce7-4d45-bdea-bb0a2c2afe65", + "assetName": "rover_1" + } + ], + "calculatedChannelQueries": [ + { + "channelReferences": [ + { + "asSelect": true, + "channelName": "vehicle_state", + "channelReference": "$1" + } + ], + "expression": "filter($1, previous($1) != $1)", + "name": "vehicle_state_changes" + } + ], + "channelQueries": [ + { + "asSelect": true + } + ], + "runQueries": [ + { + "asSelect": true + } + ] + } + ], + "combineRuns": true, + "enumDisplay": "both", + "hide": false, + "key": "", + "queryType": "", + "queryVersion": "2.1", + "refId": "" + } + }, + { + "datasource": { + "type": "sift-grafana-datasource", + "uid": "P5B56D21E16392E86" + }, + "enable": true, + "hide": false, + "iconColor": "red", + "name": "Sift Annotations", + "target": { + "annotationFilter": "asset_id == '${assetQuery}'", + "annotationType": "annotationsQuery", + "channelDataQueries": [ + { + "assetQueries": [ + { + "asSelect": true + } + ], + "calculatedChannelQueries": [], + "channelQueries": [ + { + "asSelect": true + } + ], + "runQueries": [ + { + "asSelect": true + } + ] + } + ], + "combineRuns": true, + "enumDisplay": "", + "hide": false, + "key": "", + "queryType": "", + "queryVersion": "2.1", + "refId": "" + } } ] }, diff --git a/src/components/AnnotationQueryEditor.tsx b/src/components/AnnotationQueryEditor.tsx new file mode 100644 index 0000000..fa59583 --- /dev/null +++ b/src/components/AnnotationQueryEditor.tsx @@ -0,0 +1,226 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2, QueryEditorProps } from '@grafana/data'; +import { Icon, InlineField, InlineFieldRow, Input, RadioButtonGroup, Text, TextLink, useStyles2 } from '@grafana/ui'; +import { SiftDataSource } from '../datasource'; +import { AnnotationQueryType, SiftQuery, SiftDataSourceOptions } from '../types'; +import { VisualSiftQueryEditor } from './VisualSiftQueryEditor'; + +type Props = QueryEditorProps; + +const ANNOTATION_QUERY_TYPES: Array<{ label: string; value: AnnotationQueryType; description: string }> = [ + { + label: 'Data Query', + value: 'dataQuery', + description: 'Use channel data as annotations', + }, + { + label: 'Sift Annotations', + value: 'annotationsQuery', + description: 'Query Sift annotations from the Sift API', + }, +]; + +const getStyles = (theme: GrafanaTheme2) => ({ + description: css({ + marginTop: theme.spacing(0.5), + marginBottom: theme.spacing(1), + }), + docsContent: css({ + display: 'flex', + flexDirection: 'column' as const, + gap: theme.spacing(0.5), + }), + examplesList: css({ + margin: 0, + paddingLeft: theme.spacing(2), + listStyleType: 'disc', + }), + docsSection: css({ + marginTop: theme.spacing(0.5), + }), + docsToggle: css({ + display: 'inline-flex', + alignItems: 'center', + gap: theme.spacing(0.5), + cursor: 'pointer', + background: 'none', + border: 'none', + padding: 0, + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + '&:hover': { + color: theme.colors.text.primary, + }, + }), +}); + +const AnnotationFilterDocs = () => { + const styles = useStyles2(getStyles); + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + {isOpen && ( +
+ + Use{' '} + + CEL (Common Expression Language) + {' '} + to filter annotations. Grafana dashboard variables are supported. + + + Available fields: asset_id, asset_name, name, state, annotation_type, etc. See Sift docs + for full list. + + + Examples: + +
    +
  • + + asset_name == 'my_asset' + +
  • +
  • + + asset_id == '{'${assetQuery}'}' (using a dashboard variable) + +
  • +
  • + + + annotation_type == 'ANNOTATION_TYPE_PHASE' && state == 'ANNOTATION_STATE_OPEN' + + +
  • +
+ + For full documentation, see the{' '} + + Sift documentation + + . + +
+ )} +
+ ); +}; + +export const AnnotationQueryEditor = (props: Props) => { + const { query, onChange, onRunQuery } = props; + const [annotationType, setAnnotationType] = useState('annotationsQuery'); + const [annotationFilter, setAnnotationFilter] = useState(''); + const [initialized, setInitialized] = useState(false); + + // Initialize once - set annotationType if not present + useEffect(() => { + if (initialized) { + return; + } + + const initAnnotationType = query.annotationType || 'annotationsQuery'; + setAnnotationType(initAnnotationType); + setAnnotationFilter(query.annotationFilter || ''); + + // Ensure query has annotationType set + if (!query.annotationType) { + onChange({ + ...query, + annotationType: initAnnotationType, + }); + } + + setInitialized(true); + }, [query, onChange, initialized]); + + const onAnnotationTypeChange = useCallback( + (newAnnotationType: AnnotationQueryType) => { + setAnnotationType(newAnnotationType); + onChange({ + ...query, + annotationType: newAnnotationType, + }); + onRunQuery(); + }, + [query, onChange, onRunQuery] + ); + + // Wrap onChange to always include annotationType + const onChangeWithAnnotationType = useCallback( + (updatedQuery: SiftQuery) => { + onChange({ + ...updatedQuery, + annotationType, + }); + }, + [onChange, annotationType] + ); + + const onAnnotationFilterChange = useCallback((e: React.FormEvent) => { + setAnnotationFilter(e.currentTarget.value); + }, []); + + const onAnnotationFilterBlur = useCallback(() => { + onChange({ + ...query, + annotationType, + annotationFilter, + }); + onRunQuery(); + }, [query, onChange, onRunQuery, annotationType, annotationFilter]); + + const styles = useStyles2(getStyles); + + if (!initialized) { + return
Loading...
; + } + + return ( +
+ + + + + + + {annotationType === 'dataQuery' && ( + <> +
+ + Query channel data to use as annotations. Each data point becomes an annotation. + +
+ + + )} + + {annotationType === 'annotationsQuery' && ( + <> +
+ + Query Sift Annotations from the Sift API. Time range is applied automatically. + +
+ + + + + + + + )} +
+ ); +}; diff --git a/src/datasource.ts b/src/datasource.ts index 2963934..0b54b92 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -1,4 +1,11 @@ -import { CoreApp, DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings, ScopedVars } from '@grafana/data'; +import { + AnnotationSupport, + CoreApp, + DataQueryRequest, + DataQueryResponse, + DataSourceInstanceSettings, + ScopedVars, +} from '@grafana/data'; import { DataSourceWithBackend } from '@grafana/runtime'; import { Observable, from } from 'rxjs'; import { switchMap } from 'rxjs/operators'; @@ -6,8 +13,12 @@ import { SiftVariableSupport } from 'variables'; import { SiftDataSourceCache } from './datasourceCache'; import { DEFAULT_QUERY, SiftDataSourceOptions, SiftQuery, QUERY_VERSION } from './types'; import { ensureQueryDefaults, filterQueryBeforeRequest, replaceTemplateVariablesInQuery } from './utils'; +import { AnnotationQueryEditor } from './components/AnnotationQueryEditor'; export class SiftDataSource extends DataSourceWithBackend { + annotations: AnnotationSupport = { + QueryEditor: AnnotationQueryEditor, + }; cache: SiftDataSourceCache; private readonly restApiUrl?: string; private readonly frontendUrl?: string; diff --git a/src/datasourceCache.test.ts b/src/datasourceCache.test.ts index 0b03523..6d317f6 100644 --- a/src/datasourceCache.test.ts +++ b/src/datasourceCache.test.ts @@ -1,6 +1,11 @@ import { DataQueryRequest, DataQueryResponse, FieldType, dateTime, toDataFrame, Field } from '@grafana/data'; import { of } from 'rxjs'; -import { SiftDataSourceCache, MIN_LIVE_LOOKBACK_TIME_MS, filterFrameByTimeRange } from './datasourceCache'; +import { + SiftDataSourceCache, + MIN_LIVE_LOOKBACK_TIME_MS, + filterFrameByTimeRange, + appendFramesByTime, +} from './datasourceCache'; import { SiftQuery } from './types'; // Mock the template service @@ -469,5 +474,103 @@ describe('SiftDataSourceCache', () => { } }); }); + + describe('annotation frames', () => { + const createMockAnnotationFrame = ( + annotations: Array<{ + time: number; + timeEnd?: number; + title: string; + text: string; + tags: string; + annotationId: string; + }>, + refId = 'A' + ) => { + return toDataFrame({ + name: 'annotations', + refId, + fields: [ + { name: 'time', type: FieldType.time, values: annotations.map((a) => a.time) }, + { name: 'timeEnd', type: FieldType.time, values: annotations.map((a) => a.timeEnd ?? null) }, + { name: 'title', type: FieldType.string, values: annotations.map((a) => a.title) }, + { name: 'text', type: FieldType.string, values: annotations.map((a) => a.text) }, + { name: 'tags', type: FieldType.string, values: annotations.map((a) => a.tags) }, + { name: 'annotationId', type: FieldType.string, values: annotations.map((a) => a.annotationId) }, + ], + }); + }; + + it('should append annotation frames with duplicate timestamps', () => { + // dataQuery annotations flatten wide frames, producing multiple rows at the same timestamp + const cached = createMockAnnotationFrame([ + { time: MOCK_TIME, title: 'ch1 val', text: '42', tags: '', annotationId: 'a1' }, + { time: MOCK_TIME, title: 'ch2 val', text: '99', tags: '', annotationId: 'a2' }, + { time: MOCK_TIME + 10 * MINUTE, title: 'ch1 val', text: '43', tags: '', annotationId: 'a3' }, + ]); + const fresh = createMockAnnotationFrame([ + { time: MOCK_TIME + HOUR, title: 'ch1 val', text: '50', tags: '', annotationId: 'a4' }, + { time: MOCK_TIME + HOUR, title: 'ch2 val', text: '88', tags: '', annotationId: 'a5' }, + ]); + + const merged = appendFramesByTime(cached, fresh); + + expect(merged.fields).toHaveLength(6); + expect(merged.fields.find((f) => f.name === 'time')?.values).toEqual([ + MOCK_TIME, MOCK_TIME, MOCK_TIME + 10 * MINUTE, MOCK_TIME + HOUR, MOCK_TIME + HOUR, + ]); + expect(merged.fields.find((f) => f.name === 'title')?.values).toEqual([ + 'ch1 val', 'ch2 val', 'ch1 val', 'ch1 val', 'ch2 val', + ]); + expect(merged.fields.find((f) => f.name === 'annotationId')?.values).toEqual(['a1', 'a2', 'a3', 'a4', 'a5']); + }); + + it('should invalidate cache when annotationFilter changes', async () => { + mockFetchCallback.mockImplementation(() => { + const frame = createMockAnnotationFrame([ + { time: MOCK_TIME, title: 'Ann 1', text: 'Desc', tags: '', annotationId: 'a1' }, + ]); + return of({ data: [frame] } as DataQueryResponse); + }); + + const makeRequest = (filter: string): DataQueryRequest => ({ + requestId: 'mock-request', + interval: '1m', + intervalMs: MINUTE, + panelId: 1, + range: { + from: dateTime(MOCK_TIME), + to: dateTime(MOCK_TIME + HOUR), + raw: { from: dateTime(MOCK_TIME), to: dateTime(MOCK_TIME + HOUR) }, + }, + scopedVars: {}, + targets: [ + { + refId: 'A', + queryVersion: '2', + channelDataQueries: [], + annotationType: 'annotationsQuery' as const, + annotationFilter: filter, + }, + ], + timezone: 'utc', + app: 'dashboard', + startTime: 0, + }); + + await cache.queryWithCache(makeRequest("asset_name == 'rover_1'"), mockFetchCallback); + expect(mockFetchCallback).toHaveBeenCalledTimes(1); + + mockFetchCallback.mockClear(); + + // Same filter — cache hit + await cache.queryWithCache(makeRequest("asset_name == 'rover_1'"), mockFetchCallback); + expect(mockFetchCallback).not.toHaveBeenCalled(); + + // Different filter — cache miss + await cache.queryWithCache(makeRequest("asset_name == 'rover_2'"), mockFetchCallback); + expect(mockFetchCallback).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/src/plugin.json b/src/plugin.json index d30781d..66711d2 100644 --- a/src/plugin.json +++ b/src/plugin.json @@ -4,6 +4,7 @@ "name": "Sift", "id": "sift-grafana-datasource", "metrics": true, + "annotations": true, "backend": true, "executable": "gpx_grafana_datasource", "info": { diff --git a/src/types.ts b/src/types.ts index a6cacfd..5f834f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,11 +64,15 @@ export interface ChannelDataQuery { export type EnumDisplayType = 'string' | 'value' | 'both'; +export type AnnotationQueryType = 'dataQuery' | 'annotationsQuery'; + export interface SiftQuery extends DataQuery { channelDataQueries?: ChannelDataQuery[]; combineRuns?: boolean; enumDisplay?: EnumDisplayType; queryVersion: string; + annotationType?: AnnotationQueryType; + annotationFilter?: string; } export const DEFAULT_QUERY: Partial = { diff --git a/src/utils.ts b/src/utils.ts index 5b4239c..e73d4df 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -236,6 +236,7 @@ export const replaceTemplateVariablesInQuery = (query: SiftQuery, scopedVars: Sc return { ...query, + annotationFilter: query.annotationFilter ? templateSrv.replace(query.annotationFilter, scopedVars) : query.annotationFilter, channelDataQueries: query.channelDataQueries?.map((cdq) => { return { ...cdq,