Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/v5.1.0 #163

Merged
merged 6 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,16 @@
- Changed the query editor layout
- Support Grafana version 11
- Drop support for Grafana 8.x and 9.x

## 5.1.0

- Add units and description to new format - issue #154
- Fixed digital state - issue #159
- Fixed summary data - issue #160
- Fixed an error in recorded max number of points - issue #162

- Updated the query editor layout
- Added boundary type support in recorded values
- Recognize partial usage of variables in elements
- Added configuration to hide API errors in panel
- Truncate time from grafana date time picker to seconds
2 changes: 1 addition & 1 deletion dist/module.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/module.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@
{"name": "Datasource Configuration", "path": "img/configuration.png"},
{"name": "Annotations Editor", "path": "img/annotations.png"}
],
"version": "5.0.0",
"updated": "2024-06-14"
"version": "5.1.0",
"updated": "2024-09-19"
},
"dependencies": {
"grafanaDependency": ">=10.1.0",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "grid-protection-alliance-osisoftpi-grafana",
"version": "5.0.0",
"version": "5.1.0",
"description": "OSISoft PI Grafana Plugin",
"scripts": {
"build": "webpack -c ./.config/webpack/webpack.config.ts --env production",
Expand Down
3 changes: 2 additions & 1 deletion pkg/plugin/annotation_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ func (d *Datasource) processAnnotationQuery(ctx context.Context, query backend.D
}

func (q PiProcessedAnnotationQuery) getTimeRangeURIComponent() string {
return "&startTime=" + q.TimeRange.From.UTC().Format(time.RFC3339) + "&endTime=" + q.TimeRange.To.UTC().Format(time.RFC3339)
return "&startTime=" + q.TimeRange.From.UTC().Truncate(time.Second).Format(time.RFC3339) +
"&endTime=" + q.TimeRange.To.UTC().Truncate(time.Second).Format(time.RFC3339)
}

// getEventFrameQueryURL returns the URI for the event frame query
Expand Down
11 changes: 7 additions & 4 deletions pkg/plugin/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@ func (d *Datasource) QueryAnnotations(ctx context.Context, req *backend.QueryDat
trace.WithAttributes(
attribute.String("query.ref_id", q.RefID),
attribute.String("query.type", q.QueryType),
attribute.Int64("query.time_range.from", q.TimeRange.From.Unix()),
attribute.Int64("query.time_range.to", q.TimeRange.To.Unix()),
attribute.Int64("query.time_range.from", q.TimeRange.From.Truncate(time.Second).Unix()),
attribute.Int64("query.time_range.to", q.TimeRange.To.Truncate(time.Second).Unix()),
),
)

Expand Down Expand Up @@ -232,7 +232,7 @@ func (d *Datasource) QueryAnnotations(ctx context.Context, req *backend.QueryDat
return response, nil
}

// This function provides a way to proxy requests to the PI Web API. It is used to limit access fromt he frontend to the PI Web API.
// This function provides a way to proxy requests to the PI Web API. It is used to limit access from the frontend to the PI Web API.
// These endpoints are called by the front end while configuring the datasource, query, and annotations.
func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
// Create spans for this function.
Expand Down Expand Up @@ -275,7 +275,10 @@ func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResource

r, err := apiGet(ctx, d, req.URL)
if err != nil {
return err
return sender.Send(&backend.CallResourceResponse{
Status: http.StatusNotFound,
Body: []byte(`{}`),
})
}
return sender.Send(&backend.CallResourceResponse{
Status: http.StatusOK,
Expand Down
38 changes: 21 additions & 17 deletions pkg/plugin/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,8 @@ func compatible(actual reflect.Type, expected reflect.Type) bool {
a == reflect.Float64)
}

func getDataLabels(useNewFormat bool, q *PiProcessedQuery, pointType string, summaryLabel string) map[string]string {
func getDataLabels(useNewFormat bool, q *PiProcessedQuery, pointType string, description string,
units string, summaryLabel string) map[string]string {
var frameLabel map[string]string
summaryNewFormat := ""

Expand All @@ -373,26 +374,31 @@ func getDataLabels(useNewFormat bool, q *PiProcessedQuery, pointType string, sum
} else {
label = targetParts[len(targetParts)-1]
}
label += summaryLabel
}

if q.IsPIPoint {
// New format returns the full path with metadata
// PiPoint {element="PISERVER", name="Attribute", type="Float32"}
targetParts := strings.Split(q.FullTargetPath, `\`)
frameLabel = map[string]string{
"element": targetParts[0],
"name": label,
"type": pointType + summaryNewFormat,
"element": targetParts[0],
"name": label,
"type": pointType + summaryNewFormat,
"description": description,
"units": units,
}
} else {
// New format returns the full path with metadata
// Element|Attribute {element="Element", name="Attribute", type="Single"}
targetParts := strings.Split(q.FullTargetPath, `\`)
labelParts := strings.SplitN(targetParts[len(targetParts)-1], "|", 2)
frameLabel = map[string]string{
"element": labelParts[0],
"name": label,
"type": pointType + summaryNewFormat,
"element": labelParts[0],
"name": label,
"type": pointType + summaryNewFormat,
"description": description,
"units": units,
}
}

Expand All @@ -413,7 +419,7 @@ func convertItemsToDataFrame(processedQuery *PiProcessedQuery, d *Datasource, Su
webID := processedQuery.WebID
includeMetaData := processedQuery.UseUnit
digitalStates := processedQuery.DigitalStates
noDataReplace := processedQuery.getSummaryNoDataReplace()
noDataReplace := processedQuery.getNoDataReplace()

digitalStateValues := make([]string, 0)
sliceType := d.getTypeForWebID(webID)
Expand All @@ -427,7 +433,8 @@ func convertItemsToDataFrame(processedQuery *PiProcessedQuery, d *Datasource, Su
}

// get frame name
frameLabel := getDataLabels(d.isUsingNewFormat(), processedQuery, d.getPointTypeForWebID(webID), SummaryType)
frameLabel := getDataLabels(d.isUsingNewFormat(), processedQuery, d.getPointTypeForWebID(webID),
d.getDescriptionForWebID(webID), d.getUnitsForWebID(webID), SummaryType)

var labels map[string]string
var digitalState = d.getDigitalStateForWebID(webID)
Expand Down Expand Up @@ -469,13 +476,10 @@ func convertItemsToDataFrame(processedQuery *PiProcessedQuery, d *Datasource, Su
}
}

// if the value isn't good, or is not the same type as the slice,
// add it to the list of bad values and nullify later
//TODO we should make this pattern match the query options
_, digitalState := item.Value.(map[string]interface{})
_, digitalState = item.Value.(map[string]interface{})
if !item.isGood() {
fP = updateBadData(i, fP, item.Timestamp, noDataReplace)
} else if digitalState {
} else if digitalState { // digital state
var pds PointDigitalState
if b, err := json.Marshal(item.Value); err == nil {
if err := json.Unmarshal(b, &pds); err == nil {
Expand All @@ -495,7 +499,7 @@ func convertItemsToDataFrame(processedQuery *PiProcessedQuery, d *Datasource, Su
backend.Logger.Error("Error unmarshalling digital state", err)
fP = updateBadData(i, fP, item.Timestamp, noDataReplace)
}
} else if fP.val.Type().Kind() != fP.sliceType.Elem().Kind() {
} else if fP.val.Type().Kind() != fP.sliceType.Elem().Kind() { // mismatch - try conversion
backend.Logger.Warn("Mismatch type", "ValKind", fP.val.Type().String(), "Val", fP.val.Interface(),
"SliceKind", fP.sliceType.Elem().String(), "item", item)
if compatible(fP.val.Type(), fP.sliceType.Elem()) { // try to convert if numeric values
Expand All @@ -505,7 +509,7 @@ func convertItemsToDataFrame(processedQuery *PiProcessedQuery, d *Datasource, Su
} else {
fP = updateBadData(i, fP, item.Timestamp, noDataReplace)
}
} else {
} else { // normal
fP.timestamps = append(fP.timestamps, item.Timestamp)
fP.values = reflect.Append(reflect.ValueOf(fP.values), fP.val).Interface()
fP.prevVal = fP.val
Expand All @@ -518,7 +522,7 @@ func convertItemsToDataFrame(processedQuery *PiProcessedQuery, d *Datasource, Su
// in the slice type, or values that are not "good"
valuepointers := convertSliceToPointers(fP.values, fP.badValues)

timeField := data.NewField("time", nil, fP.timestamps)
timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, fP.timestamps)
if !digitalState || !digitalStates {
valueField := data.NewField(frameLabel["name"], labels, valuepointers)
frame.Fields = append(frame.Fields,
Expand Down
58 changes: 41 additions & 17 deletions pkg/plugin/timeseries_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,15 @@ func (d *Datasource) processQuery(query backend.DataQuery, datasourceUID string)
UID: datasourceUID,
IntervalNanoSeconds: PiQuery.Interval,
IsPIPoint: PiQuery.Pi.IsPiPoint,
HideError: PiQuery.Pi.HideError,
Streamable: PiQuery.isStreamable() && *d.dataSourceOptions.UseExperimental && *d.dataSourceOptions.UseStreaming,
FullTargetPath: fullTargetPath,
TargetPath: targetBasePath,
UseUnit: UseUnits,
DigitalStates: DigitalStates,
Display: PiQuery.Pi.Display,
Regex: PiQuery.Pi.Regex,
Nodata: PiQuery.Pi.Nodata,
Summary: PiQuery.Pi.Summary,
Variable: PiQuery.Pi.getVariable(i),
Index: (j + 1) + 100*(i+1),
Expand Down Expand Up @@ -268,8 +270,10 @@ func (d *Datasource) processBatchtoFrames(processedQuery map[string][]PiProcesse
for _, q := range query {
// if there is an error in the query, we set the error in the subresponse and break out of the loop returning the error.
if q.Error != nil {
backend.Logger.Error("Error processing query", "RefID", RefID, "query", q)
subResponse.Error = q.Error
backend.Logger.Error("Error processing query", "RefID", RefID, "query", q, "hide", q.HideError)
if !q.HideError && strings.Contains(q.Error.Error(), "api error") {
subResponse.Error = q.Error
}
break
}

Expand Down Expand Up @@ -318,10 +322,10 @@ func (q *PIWebAPIQuery) isSummary() bool {
if q.Summary == nil {
return false
}
if q.Summary.Types == nil {
if q.Summary.Enable == nil {
return false
}
return *q.Summary.Basis != "" && len(*q.Summary.Types) > 0
return *q.Summary.Enable && *q.Summary.Basis != "" && len(*q.Summary.Types) > 0
}

// PiProcessedQuery isRegex returns true if the query is a regex query and is enabled
Expand Down Expand Up @@ -361,17 +365,28 @@ func (q *PiProcessedQuery) isRegexQuery() bool {
// A default of 30s is returned if the summary duration is not provided by the frontend
// or if the format is invalid
func (q *PIWebAPIQuery) getSummaryDuration() string {
backend.Logger.Debug("Summary duration", "summary", q.Summary)
// Return the default value if the summary is not provided by the frontend
if q.Summary == nil || *q.Summary.Interval == "" {
if q.Summary == nil || q.Summary.Duration == nil || *q.Summary.Duration == "" {
return "30s"
}
return _getDurationBase(*q.Summary.Duration)
}

// If the summary duration is provided, then validate the format piwebapi expects
func (q *PIWebAPIQuery) getSampleInterval() string {
// Return the default value if the summary is not provided by the frontend
if q.Summary == nil || q.Summary.SampleInterval == nil || *q.Summary.SampleInterval == "" {
return "30s"
}
return _getDurationBase(*q.Summary.SampleInterval)
}

func _getDurationBase(duration string) string {
// If the summary duration is provided, then validate the format piwebapi expects
// Regular expression to match the format: <number><short_name>
pattern := `^(\d+(\.\d+)?)\s*(ms|s|m|h|d|mo|w|wd|yd)$`
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(*q.Summary.Interval)
matches := re.FindStringSubmatch(duration)

if len(matches) != 4 {
return "30s" // Return the default value if the format is invalid
Expand All @@ -391,11 +406,11 @@ func (q *PIWebAPIQuery) getSummaryDuration() string {
switch shortName {
case "ms", "s", "m", "h":
// Fractions allowed for millisecond, second, minute, and hour
return *q.Summary.Interval
return duration
case "d", "mo", "w", "wd", "yd":
// No fractions allowed for day, month, week, weekday, yearday
if numericPart == float64(int64(numericPart)) {
return *q.Summary.Interval
return duration
}
default:
return "30s" // Return the default value if the short name or fractions are not allowed
Expand All @@ -410,7 +425,13 @@ func (q *PIWebAPIQuery) getSummaryURIComponent() string {
uri += "&summaryType=" + t.Value.Value
}
uri += "&summaryBasis=" + *q.Summary.Basis
uri += "&summaryDuration=" + q.getSummaryDuration()
if q.Summary.Duration != nil && *q.Summary.Duration != "" {
uri += "&summaryDuration=" + q.getSummaryDuration()
}
if q.Summary.SampleTypeInterval != nil && *q.Summary.SampleTypeInterval &&
q.Summary.SampleInterval != nil && *q.Summary.SampleInterval != "" {
uri += "&sampleType=Interval&sampleInterval=" + q.getSampleInterval()
}
return uri
}

Expand Down Expand Up @@ -554,6 +575,13 @@ func (q *Query) getMaxDataPoints() int {
return q.MaxDataPoints
}

func (q *Query) getBoundaryType() string {
if q.Pi.RecordedValues.BoundaryType != nil {
return *q.Pi.RecordedValues.BoundaryType
}
return "Inside"
}

func (q Query) getQueryBaseURL() string {
var uri string
if q.Pi.isExpression() {
Expand All @@ -562,10 +590,7 @@ func (q Query) getQueryBaseURL() string {
uri += "/times?time=" + q.getTimeRangeURIToComponent()
} else {
if q.Pi.isSummary() {
uri += "/summary" + q.getTimeRangeURIComponent()
if q.Pi.isInterpolated() {
uri += fmt.Sprintf("&sampleType=Interval&sampleInterval=%s", q.getIntervalTime())
}
uri += "/summary" + q.getTimeRangeURIComponent() + q.Pi.getSummaryURIComponent()
} else if q.Pi.isInterpolated() {
uri += "/intervals" + q.getTimeRangeURIComponent()
uri += fmt.Sprintf("&sampleInterval=%s", q.getIntervalTime())
Expand All @@ -587,12 +612,11 @@ func (q Query) getQueryBaseURL() string {
}
} else {
if q.Pi.isSummary() {
uri += "/summary" + q.getTimeRangeURIComponent() + fmt.Sprintf("&intervals=%d", q.getMaxDataPoints())
uri += q.Pi.getSummaryURIComponent()
uri += "/summary" + q.getTimeRangeURIComponent() + q.Pi.getSummaryURIComponent()
} else if q.Pi.isInterpolated() {
uri += "/interpolated" + q.getTimeRangeURIComponent() + fmt.Sprintf("&interval=%s", q.getIntervalTime())
} else if q.Pi.isRecordedValues() {
uri += "/recorded" + q.getTimeRangeURIComponent() + fmt.Sprintf("&maxCount=%d", q.getMaxDataPoints()) + "&boundaryType=Interpolated"
uri += "/recorded" + q.getTimeRangeURIComponent() + fmt.Sprintf("&maxCount=%d", q.getMaxDataPoints()) + "&boundaryType=" + q.getBoundaryType()
} else {
uri += "/plot" + q.getTimeRangeURIComponent() + fmt.Sprintf("&intervals=%d", q.getMaxDataPoints())
}
Expand Down
Loading
Loading