Merge pull request #547 from kaysond/master

Add support for disabling repeat notifications if the values haven't changed
pull/586/head
Jason Kulatunga 3 months ago committed by GitHub
commit 3ea223fa8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

4
.gitignore vendored

@ -65,4 +65,6 @@ scrutiny_test.db
scrutiny.yaml scrutiny.yaml
coverage.txt coverage.txt
/config /config
/influxdb /influxdb
.angular
web.log

@ -1,12 +0,0 @@
package database
import (
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"sort"
)
func sortSmartMeasurementsDesc(smartResults []measurements.Smart) {
sort.SliceStable(smartResults, func(i, j int) bool {
return smartResults[i].Date.After(smartResults[j].Date)
})
}

@ -1,30 +0,0 @@
package database
import (
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"github.com/stretchr/testify/require"
"testing"
"time"
)
func Test_sortSmartMeasurementsDesc_LatestFirst(t *testing.T) {
//setup
timeNow := time.Now()
smartResults := []measurements.Smart{
{
Date: timeNow.AddDate(0, 0, -2),
},
{
Date: timeNow,
},
{
Date: timeNow.AddDate(0, 0, -1),
},
}
//test
sortSmartMeasurementsDesc(smartResults)
//assert
require.Equal(t, smartResults[0].Date, timeNow)
}

@ -2,12 +2,15 @@ package database
import ( import (
"context" "context"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/models" "github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector" "github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
) )
// Create mock using:
// mockgen -source=webapp/backend/pkg/database/interface.go -destination=webapp/backend/pkg/database/mock/mock_database.go
type DeviceRepo interface { type DeviceRepo interface {
Close() error Close() error
HealthCheck(ctx context.Context) error HealthCheck(ctx context.Context) error
@ -20,7 +23,7 @@ type DeviceRepo interface {
DeleteDevice(ctx context.Context, wwn string) error DeleteDevice(ctx context.Context, wwn string) error
SaveSmartAttributes(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (measurements.Smart, error) SaveSmartAttributes(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (measurements.Smart, error)
GetSmartAttributeHistory(ctx context.Context, wwn string, durationKey string, attributes []string) ([]measurements.Smart, error) GetSmartAttributeHistory(ctx context.Context, wwn string, durationKey string, selectEntries int, selectEntriesOffset int, attributes []string) ([]measurements.Smart, error)
SaveSmartTemperature(ctx context.Context, wwn string, deviceProtocol string, collectorSmartData collector.SmartInfo) error SaveSmartTemperature(ctx context.Context, wwn string, deviceProtocol string, collectorSmartData collector.SmartInfo) error

@ -0,0 +1,258 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: webapp/backend/pkg/database/interface.go
// Package mock_database is a generated GoMock package.
package mock_database
import (
context "context"
reflect "reflect"
pkg "github.com/analogj/scrutiny/webapp/backend/pkg"
models "github.com/analogj/scrutiny/webapp/backend/pkg/models"
collector "github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
measurements "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
gomock "github.com/golang/mock/gomock"
)
// MockDeviceRepo is a mock of DeviceRepo interface.
type MockDeviceRepo struct {
ctrl *gomock.Controller
recorder *MockDeviceRepoMockRecorder
}
// MockDeviceRepoMockRecorder is the mock recorder for MockDeviceRepo.
type MockDeviceRepoMockRecorder struct {
mock *MockDeviceRepo
}
// NewMockDeviceRepo creates a new mock instance.
func NewMockDeviceRepo(ctrl *gomock.Controller) *MockDeviceRepo {
mock := &MockDeviceRepo{ctrl: ctrl}
mock.recorder = &MockDeviceRepoMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockDeviceRepo) EXPECT() *MockDeviceRepoMockRecorder {
return m.recorder
}
// Close mocks base method.
func (m *MockDeviceRepo) Close() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Close")
ret0, _ := ret[0].(error)
return ret0
}
// Close indicates an expected call of Close.
func (mr *MockDeviceRepoMockRecorder) Close() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockDeviceRepo)(nil).Close))
}
// DeleteDevice mocks base method.
func (m *MockDeviceRepo) DeleteDevice(ctx context.Context, wwn string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteDevice", ctx, wwn)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteDevice indicates an expected call of DeleteDevice.
func (mr *MockDeviceRepoMockRecorder) DeleteDevice(ctx, wwn interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDevice", reflect.TypeOf((*MockDeviceRepo)(nil).DeleteDevice), ctx, wwn)
}
// GetDeviceDetails mocks base method.
func (m *MockDeviceRepo) GetDeviceDetails(ctx context.Context, wwn string) (models.Device, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetDeviceDetails", ctx, wwn)
ret0, _ := ret[0].(models.Device)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetDeviceDetails indicates an expected call of GetDeviceDetails.
func (mr *MockDeviceRepoMockRecorder) GetDeviceDetails(ctx, wwn interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeviceDetails", reflect.TypeOf((*MockDeviceRepo)(nil).GetDeviceDetails), ctx, wwn)
}
// GetDevices mocks base method.
func (m *MockDeviceRepo) GetDevices(ctx context.Context) ([]models.Device, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetDevices", ctx)
ret0, _ := ret[0].([]models.Device)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetDevices indicates an expected call of GetDevices.
func (mr *MockDeviceRepoMockRecorder) GetDevices(ctx interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDevices", reflect.TypeOf((*MockDeviceRepo)(nil).GetDevices), ctx)
}
// GetSmartAttributeHistory mocks base method.
func (m *MockDeviceRepo) GetSmartAttributeHistory(ctx context.Context, wwn, durationKey string, selectEntries, selectEntriesOffset int, attributes []string) ([]measurements.Smart, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSmartAttributeHistory", ctx, wwn, durationKey, selectEntries, selectEntriesOffset, attributes)
ret0, _ := ret[0].([]measurements.Smart)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetSmartAttributeHistory indicates an expected call of GetSmartAttributeHistory.
func (mr *MockDeviceRepoMockRecorder) GetSmartAttributeHistory(ctx, wwn, durationKey, selectEntries, selectEntriesOffset, attributes interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartAttributeHistory", reflect.TypeOf((*MockDeviceRepo)(nil).GetSmartAttributeHistory), ctx, wwn, durationKey, selectEntries, selectEntriesOffset, attributes)
}
// GetSmartTemperatureHistory mocks base method.
func (m *MockDeviceRepo) GetSmartTemperatureHistory(ctx context.Context, durationKey string) (map[string][]measurements.SmartTemperature, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSmartTemperatureHistory", ctx, durationKey)
ret0, _ := ret[0].(map[string][]measurements.SmartTemperature)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetSmartTemperatureHistory indicates an expected call of GetSmartTemperatureHistory.
func (mr *MockDeviceRepoMockRecorder) GetSmartTemperatureHistory(ctx, durationKey interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartTemperatureHistory", reflect.TypeOf((*MockDeviceRepo)(nil).GetSmartTemperatureHistory), ctx, durationKey)
}
// GetSummary mocks base method.
func (m *MockDeviceRepo) GetSummary(ctx context.Context) (map[string]*models.DeviceSummary, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSummary", ctx)
ret0, _ := ret[0].(map[string]*models.DeviceSummary)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetSummary indicates an expected call of GetSummary.
func (mr *MockDeviceRepoMockRecorder) GetSummary(ctx interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSummary", reflect.TypeOf((*MockDeviceRepo)(nil).GetSummary), ctx)
}
// HealthCheck mocks base method.
func (m *MockDeviceRepo) HealthCheck(ctx context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HealthCheck", ctx)
ret0, _ := ret[0].(error)
return ret0
}
// HealthCheck indicates an expected call of HealthCheck.
func (mr *MockDeviceRepoMockRecorder) HealthCheck(ctx interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HealthCheck", reflect.TypeOf((*MockDeviceRepo)(nil).HealthCheck), ctx)
}
// LoadSettings mocks base method.
func (m *MockDeviceRepo) LoadSettings(ctx context.Context) (*models.Settings, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LoadSettings", ctx)
ret0, _ := ret[0].(*models.Settings)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LoadSettings indicates an expected call of LoadSettings.
func (mr *MockDeviceRepoMockRecorder) LoadSettings(ctx interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadSettings", reflect.TypeOf((*MockDeviceRepo)(nil).LoadSettings), ctx)
}
// RegisterDevice mocks base method.
func (m *MockDeviceRepo) RegisterDevice(ctx context.Context, dev models.Device) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RegisterDevice", ctx, dev)
ret0, _ := ret[0].(error)
return ret0
}
// RegisterDevice indicates an expected call of RegisterDevice.
func (mr *MockDeviceRepoMockRecorder) RegisterDevice(ctx, dev interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterDevice", reflect.TypeOf((*MockDeviceRepo)(nil).RegisterDevice), ctx, dev)
}
// SaveSettings mocks base method.
func (m *MockDeviceRepo) SaveSettings(ctx context.Context, settings models.Settings) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SaveSettings", ctx, settings)
ret0, _ := ret[0].(error)
return ret0
}
// SaveSettings indicates an expected call of SaveSettings.
func (mr *MockDeviceRepoMockRecorder) SaveSettings(ctx, settings interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSettings", reflect.TypeOf((*MockDeviceRepo)(nil).SaveSettings), ctx, settings)
}
// SaveSmartAttributes mocks base method.
func (m *MockDeviceRepo) SaveSmartAttributes(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (measurements.Smart, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SaveSmartAttributes", ctx, wwn, collectorSmartData)
ret0, _ := ret[0].(measurements.Smart)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SaveSmartAttributes indicates an expected call of SaveSmartAttributes.
func (mr *MockDeviceRepoMockRecorder) SaveSmartAttributes(ctx, wwn, collectorSmartData interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSmartAttributes", reflect.TypeOf((*MockDeviceRepo)(nil).SaveSmartAttributes), ctx, wwn, collectorSmartData)
}
// SaveSmartTemperature mocks base method.
func (m *MockDeviceRepo) SaveSmartTemperature(ctx context.Context, wwn, deviceProtocol string, collectorSmartData collector.SmartInfo) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SaveSmartTemperature", ctx, wwn, deviceProtocol, collectorSmartData)
ret0, _ := ret[0].(error)
return ret0
}
// SaveSmartTemperature indicates an expected call of SaveSmartTemperature.
func (mr *MockDeviceRepoMockRecorder) SaveSmartTemperature(ctx, wwn, deviceProtocol, collectorSmartData interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSmartTemperature", reflect.TypeOf((*MockDeviceRepo)(nil).SaveSmartTemperature), ctx, wwn, deviceProtocol, collectorSmartData)
}
// UpdateDevice mocks base method.
func (m *MockDeviceRepo) UpdateDevice(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (models.Device, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateDevice", ctx, wwn, collectorSmartData)
ret0, _ := ret[0].(models.Device)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateDevice indicates an expected call of UpdateDevice.
func (mr *MockDeviceRepoMockRecorder) UpdateDevice(ctx, wwn, collectorSmartData interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDevice", reflect.TypeOf((*MockDeviceRepo)(nil).UpdateDevice), ctx, wwn, collectorSmartData)
}
// UpdateDeviceStatus mocks base method.
func (m *MockDeviceRepo) UpdateDeviceStatus(ctx context.Context, wwn string, status pkg.DeviceStatus) (models.Device, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateDeviceStatus", ctx, wwn, status)
ret0, _ := ret[0].(models.Device)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateDeviceStatus indicates an expected call of UpdateDeviceStatus.
func (mr *MockDeviceRepoMockRecorder) UpdateDeviceStatus(ctx, wwn, status interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDeviceStatus", reflect.TypeOf((*MockDeviceRepo)(nil).UpdateDeviceStatus), ctx, wwn, status)
}

@ -3,13 +3,14 @@ package database
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector" "github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
influxdb2 "github.com/influxdata/influxdb-client-go/v2" influxdb2 "github.com/influxdata/influxdb-client-go/v2"
"github.com/influxdata/influxdb-client-go/v2/api" "github.com/influxdata/influxdb-client-go/v2/api"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"strings"
"time"
) )
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -30,14 +31,17 @@ func (sr *scrutinyRepository) SaveSmartAttributes(ctx context.Context, wwn strin
} }
// GetSmartAttributeHistory MUST return in sorted order, where newest entries are at the beginning of the list, and oldest are at the end. // GetSmartAttributeHistory MUST return in sorted order, where newest entries are at the beginning of the list, and oldest are at the end.
func (sr *scrutinyRepository) GetSmartAttributeHistory(ctx context.Context, wwn string, durationKey string, attributes []string) ([]measurements.Smart, error) { // When selectEntries is > 0, only the most recent selectEntries database entries are returned, starting from the selectEntriesOffset entry.
// For example, with selectEntries = 5, selectEntries = 0, the most recent 5 are returned. With selectEntries = 3, selectEntries = 2, entries
// 2 to 4 are returned (2 being the third newest, since it is zero-indexed)
func (sr *scrutinyRepository) GetSmartAttributeHistory(ctx context.Context, wwn string, durationKey string, selectEntries int, selectEntriesOffset int, attributes []string) ([]measurements.Smart, error) {
// Get SMartResults from InfluxDB // Get SMartResults from InfluxDB
//TODO: change the filter startrange to a real number. //TODO: change the filter startrange to a real number.
// Get parser flux query result // Get parser flux query result
//appConfig.GetString("web.influxdb.bucket") //appConfig.GetString("web.influxdb.bucket")
queryStr := sr.aggregateSmartAttributesQuery(wwn, durationKey) queryStr := sr.aggregateSmartAttributesQuery(wwn, durationKey, selectEntries, selectEntriesOffset, attributes)
log.Infoln(queryStr) log.Infoln(queryStr)
smartResults := []measurements.Smart{} smartResults := []measurements.Smart{}
@ -65,9 +69,6 @@ func (sr *scrutinyRepository) GetSmartAttributeHistory(ctx context.Context, wwn
return nil, err return nil, err
} }
//we have to sort the smartResults again, because the `union` command will return multiple 'tables' and only sort the records in each table.
sortSmartMeasurementsDesc(smartResults)
return smartResults, nil return smartResults, nil
//if err := device.SquashHistory(); err != nil { //if err := device.SquashHistory(); err != nil {
@ -99,7 +100,7 @@ func (sr *scrutinyRepository) saveDatapoint(influxWriteApi api.WriteAPIBlocking,
return influxWriteApi.WritePoint(ctx, p) return influxWriteApi.WritePoint(ctx, p)
} }
func (sr *scrutinyRepository) aggregateSmartAttributesQuery(wwn string, durationKey string) string { func (sr *scrutinyRepository) aggregateSmartAttributesQuery(wwn string, durationKey string, selectEntries int, selectEntriesOffset int, attributes []string) string {
/* /*
@ -108,28 +109,34 @@ func (sr *scrutinyRepository) aggregateSmartAttributesQuery(wwn string, duration
|> range(start: -1w, stop: now()) |> range(start: -1w, stop: now())
|> filter(fn: (r) => r["_measurement"] == "smart" ) |> filter(fn: (r) => r["_measurement"] == "smart" )
|> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" ) |> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" )
|> tail(n: 10, offset: 0)
|> schema.fieldsAsCols() |> schema.fieldsAsCols()
monthData = from(bucket: "metrics_weekly") monthData = from(bucket: "metrics_weekly")
|> range(start: -1mo, stop: -1w) |> range(start: -1mo, stop: -1w)
|> filter(fn: (r) => r["_measurement"] == "smart" ) |> filter(fn: (r) => r["_measurement"] == "smart" )
|> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" ) |> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" )
|> tail(n: 10, offset: 0)
|> schema.fieldsAsCols() |> schema.fieldsAsCols()
yearData = from(bucket: "metrics_monthly") yearData = from(bucket: "metrics_monthly")
|> range(start: -1y, stop: -1mo) |> range(start: -1y, stop: -1mo)
|> filter(fn: (r) => r["_measurement"] == "smart" ) |> filter(fn: (r) => r["_measurement"] == "smart" )
|> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" ) |> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" )
|> tail(n: 10, offset: 0)
|> schema.fieldsAsCols() |> schema.fieldsAsCols()
foreverData = from(bucket: "metrics_yearly") foreverData = from(bucket: "metrics_yearly")
|> range(start: -10y, stop: -1y) |> range(start: -10y, stop: -1y)
|> filter(fn: (r) => r["_measurement"] == "smart" ) |> filter(fn: (r) => r["_measurement"] == "smart" )
|> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" ) |> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" )
|> tail(n: 10, offset: 0)
|> schema.fieldsAsCols() |> schema.fieldsAsCols()
union(tables: [weekData, monthData, yearData, foreverData]) union(tables: [weekData, monthData, yearData, foreverData])
|> sort(columns: ["_time"], desc: false) |> group()
|> sort(columns: ["_time"], desc: true)
|> tail(n: 6, offset: 4)
|> yield(name: "last") |> yield(name: "last")
*/ */
@ -140,34 +147,57 @@ func (sr *scrutinyRepository) aggregateSmartAttributesQuery(wwn string, duration
nestedDurationKeys := sr.lookupNestedDurationKeys(durationKey) nestedDurationKeys := sr.lookupNestedDurationKeys(durationKey)
if len(nestedDurationKeys) == 1 {
//there's only one bucket being queried, no need to union, just aggregate the dataset and return
partialQueryStr = append(partialQueryStr, []string{
sr.generateSmartAttributesSubquery(wwn, nestedDurationKeys[0], selectEntries, selectEntriesOffset, attributes),
fmt.Sprintf(`%sData`, nestedDurationKeys[0]),
`|> sort(columns: ["_time"], desc: true)`,
`|> yield()`,
}...)
return strings.Join(partialQueryStr, "\n")
}
subQueries := []string{}
subQueryNames := []string{} subQueryNames := []string{}
for _, nestedDurationKey := range nestedDurationKeys { for _, nestedDurationKey := range nestedDurationKeys {
bucketName := sr.lookupBucketName(nestedDurationKey)
durationRange := sr.lookupDuration(nestedDurationKey)
subQueryNames = append(subQueryNames, fmt.Sprintf(`%sData`, nestedDurationKey)) subQueryNames = append(subQueryNames, fmt.Sprintf(`%sData`, nestedDurationKey))
partialQueryStr = append(partialQueryStr, []string{ if selectEntries > 0 {
fmt.Sprintf(`%sData = from(bucket: "%s")`, nestedDurationKey, bucketName), // We only need the last `n + offset` # of entries from each table to guarantee we can
fmt.Sprintf(`|> range(start: %s, stop: %s)`, durationRange[0], durationRange[1]), // get the last `n` # of entries starting from `offset` of the union
`|> filter(fn: (r) => r["_measurement"] == "smart" )`, subQueries = append(subQueries, sr.generateSmartAttributesSubquery(wwn, nestedDurationKey, selectEntries+selectEntriesOffset, 0, attributes))
fmt.Sprintf(`|> filter(fn: (r) => r["device_wwn"] == "%s" )`, wwn), } else {
"|> schema.fieldsAsCols()", subQueries = append(subQueries, sr.generateSmartAttributesSubquery(wwn, nestedDurationKey, 0, 0, attributes))
}...) }
}
partialQueryStr = append(partialQueryStr, subQueries...)
partialQueryStr = append(partialQueryStr, []string{
fmt.Sprintf("union(tables: [%s])", strings.Join(subQueryNames, ", ")),
`|> group()`,
`|> sort(columns: ["_time"], desc: true)`,
}...)
if selectEntries > 0 {
partialQueryStr = append(partialQueryStr, fmt.Sprintf(`|> tail(n: %d, offset: %d)`, selectEntries, selectEntriesOffset))
} }
partialQueryStr = append(partialQueryStr, `|> yield(name: "last")`)
if len(subQueryNames) == 1 { return strings.Join(partialQueryStr, "\n")
//there's only one bucket being queried, no need to union, just aggregate the dataset and return }
partialQueryStr = append(partialQueryStr, []string{
subQueryNames[0], func (sr *scrutinyRepository) generateSmartAttributesSubquery(wwn string, durationKey string, selectEntries int, selectEntriesOffset int, attributes []string) string {
`|> yield()`, bucketName := sr.lookupBucketName(durationKey)
}...) durationRange := sr.lookupDuration(durationKey)
} else {
partialQueryStr = append(partialQueryStr, []string{ partialQueryStr := []string{
fmt.Sprintf("union(tables: [%s])", strings.Join(subQueryNames, ", ")), fmt.Sprintf(`%sData = from(bucket: "%s")`, durationKey, bucketName),
`|> sort(columns: ["_time"], desc: false)`, fmt.Sprintf(`|> range(start: %s, stop: %s)`, durationRange[0], durationRange[1]),
`|> yield(name: "last")`, `|> filter(fn: (r) => r["_measurement"] == "smart" )`,
}...) fmt.Sprintf(`|> filter(fn: (r) => r["device_wwn"] == "%s" )`, wwn),
}
if selectEntries > 0 {
partialQueryStr = append(partialQueryStr, fmt.Sprintf(`|> tail(n: %d, offset: %d)`, selectEntries, selectEntriesOffset))
} }
partialQueryStr = append(partialQueryStr, "|> schema.fieldsAsCols()")
return strings.Join(partialQueryStr, "\n") return strings.Join(partialQueryStr, "\n")
} }

@ -4,6 +4,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strconv"
"time"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20201107210306" "github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20201107210306"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220503120000" "github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220503120000"
@ -17,8 +20,6 @@ import (
"github.com/influxdata/influxdb-client-go/v2/api/http" "github.com/influxdata/influxdb-client-go/v2/api/http"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
"strconv"
"time"
) )
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -369,6 +370,21 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
return tx.Create(&defaultSettings).Error return tx.Create(&defaultSettings).Error
}, },
}, },
{
ID: "m20231123123300", // add repeat_notifications setting.
Migrate: func(tx *gorm.DB) error {
//add repeat_notifications setting default.
var defaultSettings = []m20220716214900.Setting{
{
SettingKeyName: "metrics.repeat_notifications",
SettingKeyDescription: "Whether to repeat all notifications or just when values change (true | false)",
SettingDataType: "bool",
SettingValueBool: true,
},
}
return tx.Create(&defaultSettings).Error
},
},
}) })
if err := m.Migrate(); err != nil { if err := m.Migrate(); err != nil {

@ -2,10 +2,11 @@ package measurements
import ( import (
"fmt" "fmt"
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"strconv" "strconv"
"strings" "strings"
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
) )
type SmartAtaAttribute struct { type SmartAtaAttribute struct {
@ -24,6 +25,10 @@ type SmartAtaAttribute struct {
FailureRate float64 `json:"failure_rate,omitempty"` FailureRate float64 `json:"failure_rate,omitempty"`
} }
func (sa *SmartAtaAttribute) GetTransformedValue() int64 {
return sa.TransformedValue
}
func (sa *SmartAtaAttribute) GetStatus() pkg.AttributeStatus { func (sa *SmartAtaAttribute) GetStatus() pkg.AttributeStatus {
return sa.Status return sa.Status
} }

@ -6,4 +6,5 @@ type SmartAttribute interface {
Flatten() (fields map[string]interface{}) Flatten() (fields map[string]interface{})
Inflate(key string, val interface{}) Inflate(key string, val interface{})
GetStatus() pkg.AttributeStatus GetStatus() pkg.AttributeStatus
GetTransformedValue() int64
} }

@ -2,9 +2,10 @@ package measurements
import ( import (
"fmt" "fmt"
"strings"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds" "github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"strings"
) )
type SmartNvmeAttribute struct { type SmartNvmeAttribute struct {
@ -18,6 +19,10 @@ type SmartNvmeAttribute struct {
FailureRate float64 `json:"failure_rate,omitempty"` FailureRate float64 `json:"failure_rate,omitempty"`
} }
func (sa *SmartNvmeAttribute) GetTransformedValue() int64 {
return sa.TransformedValue
}
func (sa *SmartNvmeAttribute) GetStatus() pkg.AttributeStatus { func (sa *SmartNvmeAttribute) GetStatus() pkg.AttributeStatus {
return sa.Status return sa.Status
} }

@ -2,9 +2,10 @@ package measurements
import ( import (
"fmt" "fmt"
"strings"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds" "github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"strings"
) )
type SmartScsiAttribute struct { type SmartScsiAttribute struct {
@ -18,6 +19,10 @@ type SmartScsiAttribute struct {
FailureRate float64 `json:"failure_rate,omitempty"` FailureRate float64 `json:"failure_rate,omitempty"`
} }
func (sa *SmartScsiAttribute) GetTransformedValue() int64 {
return sa.TransformedValue
}
func (sa *SmartScsiAttribute) GetStatus() pkg.AttributeStatus { func (sa *SmartScsiAttribute) GetStatus() pkg.AttributeStatus {
return sa.Status return sa.Status
} }

@ -17,8 +17,9 @@ type Settings struct {
LineStroke string `json:"line_stroke" mapstructure:"line_stroke"` LineStroke string `json:"line_stroke" mapstructure:"line_stroke"`
Metrics struct { Metrics struct {
NotifyLevel int `json:"notify_level" mapstructure:"notify_level"` NotifyLevel int `json:"notify_level" mapstructure:"notify_level"`
StatusFilterAttributes int `json:"status_filter_attributes" mapstructure:"status_filter_attributes"` StatusFilterAttributes int `json:"status_filter_attributes" mapstructure:"status_filter_attributes"`
StatusThreshold int `json:"status_threshold" mapstructure:"status_threshold"` StatusThreshold int `json:"status_threshold" mapstructure:"status_threshold"`
RepeatNotifications bool `json:"repeat_notifications" mapstructure:"repeat_notifications"`
} `json:"metrics" mapstructure:"metrics"` } `json:"metrics" mapstructure:"metrics"`
} }

@ -4,13 +4,14 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"io" "io"
"io/ioutil" "io/ioutil"
"log" "log"
"net/http" "net/http"
"os" "os"
"time" "time"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
) )
func main() { func main() {
@ -32,7 +33,7 @@ func main() {
log.Fatalf("ERROR %v", err) log.Fatalf("ERROR %v", err)
} }
defer file.Close() defer file.Close()
_, err = SendPostRequest("http://localhost:9090/api/devices/register", file) _, err = SendPostRequest("http://localhost:8080/api/devices/register", file)
if err != nil { if err != nil {
log.Fatalf("ERROR %v", err) log.Fatalf("ERROR %v", err)
} }
@ -46,7 +47,7 @@ func main() {
log.Fatalf("ERROR %v", err) log.Fatalf("ERROR %v", err)
} }
_, err = SendPostRequest(fmt.Sprintf("http://localhost:9090/api/device/%s/smart", diskId), smartDataReader) _, err = SendPostRequest(fmt.Sprintf("http://localhost:8080/api/device/%s/smart", diskId), smartDataReader)
if err != nil { if err != nil {
log.Fatalf("ERROR %v", err) log.Fatalf("ERROR %v", err)
} }

@ -15,11 +15,13 @@ import (
"github.com/analogj/go-util/utils" "github.com/analogj/go-util/utils"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/config"
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/analogj/scrutiny/webapp/backend/pkg/models" "github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds" "github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"github.com/containrrr/shoutrrr" "github.com/containrrr/shoutrrr"
shoutrrrTypes "github.com/containrrr/shoutrrr/pkg/types" shoutrrrTypes "github.com/containrrr/shoutrrr/pkg/types"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
) )
@ -30,7 +32,7 @@ const NotifyFailureTypeSmartFailure = "SmartFailure"
const NotifyFailureTypeScrutinyFailure = "ScrutinyFailure" const NotifyFailureTypeScrutinyFailure = "ScrutinyFailure"
// ShouldNotify check if the error Message should be filtered (level mismatch or filtered_attributes) // ShouldNotify check if the error Message should be filtered (level mismatch or filtered_attributes)
func ShouldNotify(device models.Device, smartAttrs measurements.Smart, statusThreshold pkg.MetricsStatusThreshold, statusFilterAttributes pkg.MetricsStatusFilterAttributes) bool { func ShouldNotify(logger logrus.FieldLogger, device models.Device, smartAttrs measurements.Smart, statusThreshold pkg.MetricsStatusThreshold, statusFilterAttributes pkg.MetricsStatusFilterAttributes, repeatNotifications bool, c *gin.Context, deviceRepo database.DeviceRepo) bool {
// 1. check if the device is healthy // 1. check if the device is healthy
if device.DeviceStatus == pkg.DeviceStatusPassed { if device.DeviceStatus == pkg.DeviceStatusPassed {
return false return false
@ -54,52 +56,69 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, statusThr
requiredAttrStatus = pkg.AttributeStatusFailedScrutiny requiredAttrStatus = pkg.AttributeStatusFailedScrutiny
} }
// 2. check if the attributes that are failing should be filtered (non-critical) // This is the only case where individual attributes need not be considered
// 3. for any unfiltered attribute, store the failure reason (Smart or Scrutiny) if statusFilterAttributes == pkg.MetricsStatusFilterAttributesAll && repeatNotifications {
if statusFilterAttributes == pkg.MetricsStatusFilterAttributesCritical { return pkg.DeviceStatusHas(device.DeviceStatus, requiredDeviceStatus)
hasFailingCriticalAttr := false }
var statusFailingCriticalAttr pkg.AttributeStatus
for attrId, attrData := range smartAttrs.Attributes {
//find failing attribute
if attrData.GetStatus() == pkg.AttributeStatusPassed {
continue //skip all passing attributes
}
// merge the status's of all critical attributes var failingAttributes []string
statusFailingCriticalAttr = pkg.AttributeStatusSet(statusFailingCriticalAttr, attrData.GetStatus()) // Loop through the attributes to find the failing ones
for attrId, attrData := range smartAttrs.Attributes {
var status pkg.AttributeStatus = attrData.GetStatus()
// Skip over passing attributes
if status == pkg.AttributeStatusPassed {
continue
}
//found a failing attribute, see if its critical // If the user only wants to consider critical attributes, we have to check
if device.IsScsi() && thresholds.ScsiMetadata[attrId].Critical { // if the not-passing attribute is critical or not
hasFailingCriticalAttr = true if statusFilterAttributes == pkg.MetricsStatusFilterAttributesCritical {
} else if device.IsNvme() && thresholds.NmveMetadata[attrId].Critical { critical := false
hasFailingCriticalAttr = true if device.IsScsi() {
critical = thresholds.ScsiMetadata[attrId].Critical
} else if device.IsNvme() {
critical = thresholds.NmveMetadata[attrId].Critical
} else { } else {
//this is ATA //this is ATA
attrIdInt, err := strconv.Atoi(attrId) attrIdInt, err := strconv.Atoi(attrId)
if err != nil { if err != nil {
continue continue
} }
if thresholds.AtaMetadata[attrIdInt].Critical { critical = thresholds.AtaMetadata[attrIdInt].Critical
hasFailingCriticalAttr = true }
} // Skip non-critical, non-passing attributes when this setting is on
if !critical {
continue
} }
} }
if !hasFailingCriticalAttr { // Record any attribute that doesn't get skipped by the above two checks
//no critical attributes are failing, and notifyFilterAttributes == "critical" failingAttributes = append(failingAttributes, attrId)
return false }
} else {
// check if any of the critical attributes have a status that we're looking for
return pkg.AttributeStatusHas(statusFailingCriticalAttr, requiredAttrStatus)
}
} else { // If the user doesn't want repeated notifications when the failing value doesn't change, we need to get the last value from the db
// 2. SKIP - we are processing every attribute. var lastPoints []measurements.Smart
// 3. check if the device failure level matches the wanted failure level. var err error
return pkg.DeviceStatusHas(device.DeviceStatus, requiredDeviceStatus) if !repeatNotifications {
lastPoints, err = deviceRepo.GetSmartAttributeHistory(c, c.Param("wwn"), database.DURATION_KEY_FOREVER, 1, 1, failingAttributes)
if err == nil || len(lastPoints) < 1 {
logger.Warningln("Could not get the most recent data points from the database. This is expected to happen only if this is the very first submission of data for the device.")
}
}
for _, attrId := range failingAttributes {
attrStatus := smartAttrs.Attributes[attrId].GetStatus()
if pkg.AttributeStatusHas(attrStatus, requiredAttrStatus) {
if repeatNotifications {
return true
}
// This is checked again here to avoid repeating the entire for loop in the check above.
// Probably unnoticeably worse performance, but cleaner code.
if err != nil || len(lastPoints) < 1 || lastPoints[0].Attributes[attrId].GetTransformedValue() != smartAttrs.Attributes[attrId].GetTransformedValue() {
return true
}
}
} }
return false
} }
// TODO: include user label for device. // TODO: include user label for device.
@ -222,7 +241,7 @@ func (n *Notify) Send() error {
notifyScripts := []string{} notifyScripts := []string{}
notifyShoutrrr := []string{} notifyShoutrrr := []string{}
for ndx, _ := range configUrls { for ndx := range configUrls {
if strings.HasPrefix(configUrls[ndx], "https://") || strings.HasPrefix(configUrls[ndx], "http://") { if strings.HasPrefix(configUrls[ndx], "https://") || strings.HasPrefix(configUrls[ndx], "http://") {
notifyWebhooks = append(notifyWebhooks, configUrls[ndx]) notifyWebhooks = append(notifyWebhooks, configUrls[ndx])
} else if strings.HasPrefix(configUrls[ndx], "script://") { } else if strings.HasPrefix(configUrls[ndx], "script://") {

@ -1,13 +1,20 @@
package notify package notify
import ( import (
"errors"
"fmt" "fmt"
"testing"
"time"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
mock_database "github.com/analogj/scrutiny/webapp/backend/pkg/database/mock"
"github.com/analogj/scrutiny/webapp/backend/pkg/models" "github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"github.com/gin-gonic/gin"
"github.com/golang/mock/gomock"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"testing"
"time"
) )
func TestShouldNotify_MustSkipPassingDevices(t *testing.T) { func TestShouldNotify_MustSkipPassingDevices(t *testing.T) {
@ -20,22 +27,27 @@ func TestShouldNotify_MustSkipPassingDevices(t *testing.T) {
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusThresholdBoth_FailingSmartDevice(t *testing.T) { func TestShouldNotify_MetricsStatusThresholdBoth_FailingSmartDevice(t *testing.T) {
t.Parallel() t.Parallel()
//setup //setupD
device := models.Device{ device := models.Device{
DeviceStatus: pkg.DeviceStatusFailedSmart, DeviceStatus: pkg.DeviceStatusFailedSmart,
} }
smartAttrs := measurements.Smart{} smartAttrs := measurements.Smart{}
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusThresholdSmart_FailingSmartDevice(t *testing.T) { func TestShouldNotify_MetricsStatusThresholdSmart_FailingSmartDevice(t *testing.T) {
@ -47,9 +59,11 @@ func TestShouldNotify_MetricsStatusThresholdSmart_FailingSmartDevice(t *testing.
smartAttrs := measurements.Smart{} smartAttrs := measurements.Smart{}
statusThreshold := pkg.MetricsStatusThresholdSmart statusThreshold := pkg.MetricsStatusThresholdSmart
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusThresholdScrutiny_FailingSmartDevice(t *testing.T) { func TestShouldNotify_MetricsStatusThresholdScrutiny_FailingSmartDevice(t *testing.T) {
@ -61,9 +75,11 @@ func TestShouldNotify_MetricsStatusThresholdScrutiny_FailingSmartDevice(t *testi
smartAttrs := measurements.Smart{} smartAttrs := measurements.Smart{}
statusThreshold := pkg.MetricsStatusThresholdScrutiny statusThreshold := pkg.MetricsStatusThresholdScrutiny
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithCriticalAttrs(t *testing.T) { func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithCriticalAttrs(t *testing.T) {
@ -79,9 +95,12 @@ func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithCriticalAttrs(t
}} }}
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithMultipleCriticalAttrs(t *testing.T) { func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithMultipleCriticalAttrs(t *testing.T) {
@ -100,9 +119,12 @@ func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithMultipleCritical
}} }}
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoCriticalAttrs(t *testing.T) { func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoCriticalAttrs(t *testing.T) {
@ -118,9 +140,12 @@ func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoCriticalAttrs(
}} }}
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoFailingCriticalAttrs(t *testing.T) { func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoFailingCriticalAttrs(t *testing.T) {
@ -136,9 +161,12 @@ func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoFailingCritica
}} }}
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusFilterAttributesCritical_MetricsStatusThresholdSmart_WithCriticalAttrsFailingScrutiny(t *testing.T) { func TestShouldNotify_MetricsStatusFilterAttributesCritical_MetricsStatusThresholdSmart_WithCriticalAttrsFailingScrutiny(t *testing.T) {
@ -157,9 +185,77 @@ func TestShouldNotify_MetricsStatusFilterAttributesCritical_MetricsStatusThresho
}} }}
statusThreshold := pkg.MetricsStatusThresholdSmart statusThreshold := pkg.MetricsStatusThresholdSmart
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert
require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
}
func TestShouldNotify_NoRepeat_DatabaseFailure(t *testing.T) {
t.Parallel()
//setup
device := models.Device{
DeviceStatus: pkg.DeviceStatusFailedScrutiny,
}
smartAttrs := measurements.Smart{Attributes: map[string]measurements.SmartAttribute{
"5": &measurements.SmartAtaAttribute{
Status: pkg.AttributeStatusFailedScrutiny,
},
}}
statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
fakeDatabase.EXPECT().GetSmartAttributeHistory(&gin.Context{}, "", database.DURATION_KEY_FOREVER, 1, 1, []string{"5"}).Return([]measurements.Smart{}, errors.New("")).Times(1)
//assert
require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, false, &gin.Context{}, fakeDatabase))
}
func TestShouldNotify_NoRepeat_NoDatabaseData(t *testing.T) {
t.Parallel()
//setup
device := models.Device{
DeviceStatus: pkg.DeviceStatusFailedScrutiny,
}
smartAttrs := measurements.Smart{Attributes: map[string]measurements.SmartAttribute{
"5": &measurements.SmartAtaAttribute{
Status: pkg.AttributeStatusFailedScrutiny,
},
}}
statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
fakeDatabase.EXPECT().GetSmartAttributeHistory(&gin.Context{}, "", database.DURATION_KEY_FOREVER, 1, 1, []string{"5"}).Return([]measurements.Smart{}, nil).Times(1)
//assert
require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, false, &gin.Context{}, fakeDatabase))
}
func TestShouldNotify_NoRepeat(t *testing.T) {
t.Parallel()
//setup
device := models.Device{
DeviceStatus: pkg.DeviceStatusFailedScrutiny,
}
smartAttrs := measurements.Smart{Attributes: map[string]measurements.SmartAttribute{
"5": &measurements.SmartAtaAttribute{
Status: pkg.AttributeStatusFailedScrutiny,
TransformedValue: 0,
},
}}
statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
fakeDatabase.EXPECT().GetSmartAttributeHistory(&gin.Context{}, "", database.DURATION_KEY_FOREVER, 1, 1, []string{"5"}).Return([]measurements.Smart{smartAttrs}, nil).Times(1)
//assert //assert
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, false, &gin.Context{}, fakeDatabase))
} }
func TestNewPayload(t *testing.T) { func TestNewPayload(t *testing.T) {

@ -1,11 +1,12 @@
package handler package handler
import ( import (
"net/http"
"github.com/analogj/scrutiny/webapp/backend/pkg/database" "github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds" "github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"net/http"
) )
func GetDeviceDetails(c *gin.Context) { func GetDeviceDetails(c *gin.Context) {
@ -24,7 +25,7 @@ func GetDeviceDetails(c *gin.Context) {
durationKey = "forever" durationKey = "forever"
} }
smartResults, err := deviceRepo.GetSmartAttributeHistory(c, c.Param("wwn"), durationKey, nil) smartResults, err := deviceRepo.GetSmartAttributeHistory(c, c.Param("wwn"), durationKey, 0, 0, nil)
if err != nil { if err != nil {
logger.Errorln("An error occurred while retrieving device smart results", err) logger.Errorln("An error occurred while retrieving device smart results", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false}) c.JSON(http.StatusInternalServerError, gin.H{"success": false})

@ -2,6 +2,8 @@ package handler
import ( import (
"fmt" "fmt"
"net/http"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/config"
"github.com/analogj/scrutiny/webapp/backend/pkg/database" "github.com/analogj/scrutiny/webapp/backend/pkg/database"
@ -9,7 +11,6 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg/notify" "github.com/analogj/scrutiny/webapp/backend/pkg/notify"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"net/http"
) )
func UploadDeviceMetrics(c *gin.Context) { func UploadDeviceMetrics(c *gin.Context) {
@ -69,10 +70,14 @@ func UploadDeviceMetrics(c *gin.Context) {
//check for error //check for error
if notify.ShouldNotify( if notify.ShouldNotify(
logger,
updatedDevice, updatedDevice,
smartData, smartData,
pkg.MetricsStatusThreshold(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY))), pkg.MetricsStatusThreshold(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY))),
pkg.MetricsStatusFilterAttributes(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY))), pkg.MetricsStatusFilterAttributes(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY))),
appConfig.GetBool(fmt.Sprintf("%s.metrics.repeat_notifications", config.DB_USER_SETTINGS_SUBKEY)),
c,
deviceRepo,
) { ) {
//send notifications //send notifications

@ -4,6 +4,16 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path"
"strings"
"testing"
"time"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/config"
mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock" mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock"
@ -14,15 +24,6 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path"
"strings"
"testing"
"time"
) )
/* /*
@ -189,6 +190,7 @@ func (suite *ServerTestSuite) TestUploadDeviceMetricsRoute() {
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("user.metrics.repeat_notifications").Return(true).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions { if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
@ -247,6 +249,7 @@ func (suite *ServerTestSuite) TestPopulateMultiple() {
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("user.metrics.repeat_notifications").Return(true).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions { if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
@ -529,6 +532,7 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("user.metrics.repeat_notifications").Return(true).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{}) fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{})

@ -55,6 +55,7 @@ export interface AppConfig {
notify_level?: MetricsNotifyLevel notify_level?: MetricsNotifyLevel
status_filter_attributes?: MetricsStatusFilterAttributes status_filter_attributes?: MetricsStatusFilterAttributes
status_threshold?: MetricsStatusThreshold status_threshold?: MetricsStatusThreshold
repeat_notifications?: boolean
} }
} }
@ -82,7 +83,8 @@ export const appConfig: AppConfig = {
metrics: { metrics: {
notify_level: MetricsNotifyLevel.Fail, notify_level: MetricsNotifyLevel.Fail,
status_filter_attributes: MetricsStatusFilterAttributes.All, status_filter_attributes: MetricsStatusFilterAttributes.All,
status_threshold: MetricsStatusThreshold.Both status_threshold: MetricsStatusThreshold.Both,
repeat_notifications: true
} }
}; };

@ -84,6 +84,16 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
<mat-label>Repeat Notifications</mat-label>
<mat-select [(ngModel)]=repeatNotifications>
<mat-option [value]=true>Always</mat-option>
<mat-option [value]=false>Only when the value has changed</mat-option>
</mat-select>
</mat-form-field>
</div>
</div> </div>
</mat-dialog-content> </mat-dialog-content>

@ -28,6 +28,7 @@ export class DashboardSettingsComponent implements OnInit {
theme: string; theme: string;
statusThreshold: number; statusThreshold: number;
statusFilterAttributes: number; statusFilterAttributes: number;
repeatNotifications: boolean;
// Private // Private
private _unsubscribeAll: Subject<void>; private _unsubscribeAll: Subject<void>;
@ -55,6 +56,7 @@ export class DashboardSettingsComponent implements OnInit {
this.statusFilterAttributes = config.metrics.status_filter_attributes; this.statusFilterAttributes = config.metrics.status_filter_attributes;
this.statusThreshold = config.metrics.status_threshold; this.statusThreshold = config.metrics.status_threshold;
this.repeatNotifications = config.metrics.repeat_notifications;
}); });
@ -70,7 +72,8 @@ export class DashboardSettingsComponent implements OnInit {
theme: this.theme as Theme, theme: this.theme as Theme,
metrics: { metrics: {
status_filter_attributes: this.statusFilterAttributes as MetricsStatusFilterAttributes, status_filter_attributes: this.statusFilterAttributes as MetricsStatusFilterAttributes,
status_threshold: this.statusThreshold as MetricsStatusThreshold status_threshold: this.statusThreshold as MetricsStatusThreshold,
repeat_notifications: this.repeatNotifications
} }
} }
this._configService.config = newSettings this._configService.config = newSettings

Loading…
Cancel
Save