diff --git a/docs/INSTALL_NAS.md.go b/docs/INSTALL_NAS.md similarity index 100% rename from docs/INSTALL_NAS.md.go rename to docs/INSTALL_NAS.md diff --git a/docs/dbdiagram.io.txt b/docs/dbdiagram.io.txt index 7d23af7..23265ad 100644 --- a/docs/dbdiagram.io.txt +++ b/docs/dbdiagram.io.txt @@ -1,62 +1,88 @@ // SQLite Table(s) -Table device { - created_at timestamp - - wwn varchar [pk] - - //user provided - label varchar - host_id varchar - - // smartctl provided - device_name varchar - manufacturer varchar - model_name varchar - interface_type varchar - interface_speed varchar - serial_number varchar - firmware varchar - rotational_speed varchar - capacity varchar - form_factor varchar - smart_support varchar - device_protocol varchar - device_type varchar +Table Device { + //GORM attributes, see: http://gorm.io/docs/conventions.html + CreatedAt time + UpdatedAt time + DeletedAt time + + WWN string + + DeviceName string + DeviceUUID string + DeviceSerialID string + DeviceLabel string + + Manufacturer string + ModelName string + InterfaceType string + InterfaceSpeed string + SerialNumber string + Firmware string + RotationSpeed int + Capacity int64 + FormFactor string + SmartSupport bool + DeviceProtocol string//protocol determines which smart attribute types are available (ATA, NVMe, SCSI) + DeviceType string//device type is used for querying with -d/t flag, should only be used by collector. + + // User provided metadata + Label string + HostId string + + // Data set by Scrutiny + DeviceStatus enum } +Table Setting { + //GORM attributes, see: http://gorm.io/docs/conventions.html -// InfluxDB Tables -Table device_temperature { - //timestamp - created_at timestamp - - //tags (indexed & queryable) - device_wwn varchar [pk] - - //fields - temp bigint - } - + SettingKeyName string + SettingKeyDescription string + SettingDataType string -Table smart_ata_results { - //timestamp - created_at timestamp - - //tags (indexed & queryable) - device_wwn varchar [pk] - smart_status varchar - scrutiny_status varchar + SettingValueNumeric int64 + SettingValueString string +} +// InfluxDB Tables +Table SmartTemperature { + Date time + DeviceWWN string //(tag) + Temp int64 +} - //fields - temp bigint - power_on_hours bigint - power_cycle_count bigint +Table Smart { + Date time + DeviceWWN string //(tag) + DeviceProtocol string + + //Metrics (fields) + Temp int64 + PowerOnHours int64 + PowerCycleCount int64 + + //Smart Status + Status enum + + //SMART Attributes (fields) + Attr_ID_AttributeId int + Attr_ID_Value int64 + Attr_ID_Threshold int64 + Attr_ID_Worst int64 + Attr_ID_RawValue int64 + Attr_ID_RawString string + Attr_ID_WhenFailed string + //Generated data + Attr_ID_TransformedValue int64 + Attr_ID_Status enum + Attr_ID_StatusReason string + Attr_ID_FailureRate float64 } -Ref: device.wwn < smart_ata_results.device_wwn +Ref: Device.WWN < Smart.DeviceWWN +Ref: Device.WWN < SmartTemperature.DeviceWWN diff --git a/webapp/backend/pkg/config/config.go b/webapp/backend/pkg/config/config.go index 5f5d4cc..3961373 100644 --- a/webapp/backend/pkg/config/config.go +++ b/webapp/backend/pkg/config/config.go @@ -2,7 +2,6 @@ package config import ( "github.com/analogj/go-util/utils" - "github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg/errors" "github.com/spf13/viper" "log" @@ -10,6 +9,8 @@ import ( "strings" ) +const DB_USER_SETTINGS_SUBKEY = "user" + // When initializing this class the following methods must be called: // Config.New // Config.Init @@ -39,8 +40,6 @@ func (c *configuration) Init() error { c.SetDefault("log.file", "") c.SetDefault("notify.urls", []string{}) - c.SetDefault("notify.filter_attributes", pkg.NotifyFilterAttributesAll) - c.SetDefault("notify.level", pkg.NotifyLevelFail) c.SetDefault("web.influxdb.scheme", "http") c.SetDefault("web.influxdb.host", "localhost") @@ -55,17 +54,6 @@ func (c *configuration) Init() error { //c.SetDefault("disks.include", []string{}) //c.SetDefault("disks.exclude", []string{}) - //c.SetDefault("notify.metric.script", "/opt/scrutiny/config/notify-metrics.sh") - //c.SetDefault("notify.long.script", "/opt/scrutiny/config/notify-long-test.sh") - //c.SetDefault("notify.short.script", "/opt/scrutiny/config/notify-short-test.sh") - - //c.SetDefault("collect.metric.enable", true) - //c.SetDefault("collect.metric.command", "-a -o on -S on") - //c.SetDefault("collect.long.enable", true) - //c.SetDefault("collect.long.command", "-a -o on -S on") - //c.SetDefault("collect.short.enable", true) - //c.SetDefault("collect.short.command", "-a -o on -S on") - //if you want to load a non-standard location system config file (~/drawbridge.yml), use ReadConfig c.SetConfigType("yaml") //c.SetConfigName("drawbridge") @@ -77,7 +65,18 @@ func (c *configuration) Init() error { c.AutomaticEnv() //CLI options will be added via the `Set()` function - return nil + return c.ValidateConfig() +} + +func (c *configuration) SubKeys(key string) []string { + return c.Sub(key).AllKeys() +} + +func (c *configuration) Sub(key string) Interface { + config := configuration{ + Viper: c.Viper.Sub(key), + } + return &config } func (c *configuration) ReadConfig(configFilePath string) error { @@ -120,24 +119,18 @@ func (c *configuration) ReadConfig(configFilePath string) error { // This function ensures that the merged config works correctly. func (c *configuration) ValidateConfig() error { - ////deserialize Questions - //questionsMap := map[string]Question{} - //err := c.UnmarshalKey("questions", &questionsMap) - // - //if err != nil { - // log.Printf("questions could not be deserialized correctly. %v", err) - // return err - //} - // - //for _, v := range questionsMap { - // - // typeContent, ok := v.Schema["type"].(string) - // if !ok || len(typeContent) == 0 { - // return errors.QuestionSyntaxError("`type` is required for questions") - // } - //} - // - // + //the following keys are deprecated, and no longer supported + /* + - notify.filter_attributes (replaced by metrics.status.filter_attributes SETTING) + - notify.level (replaced by metrics.notify.level and metrics.status.threshold SETTING) + */ + //TODO add docs and upgrade doc. + if c.IsSet("notify.filter_attributes") { + return errors.ConfigValidationError("`notify.filter_attributes` configuration option is deprecated. Replaced by option in Dashboard Settings page") + } + if c.IsSet("notify.level") { + return errors.ConfigValidationError("`notify.level` configuration option is deprecated. Replaced by option in Dashboard Settings page") + } return nil } diff --git a/webapp/backend/pkg/config/config_test.go b/webapp/backend/pkg/config/config_test.go new file mode 100644 index 0000000..f734d50 --- /dev/null +++ b/webapp/backend/pkg/config/config_test.go @@ -0,0 +1,34 @@ +package config + +import ( + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "testing" +) + +func Test_MergeConfigMap(t *testing.T) { + //setup + testConfig := configuration{ + Viper: viper.New(), + } + testConfig.Set("user.dashboard_display", "hello") + testConfig.SetDefault("user.layout", "hello") + + mergeSettings := map[string]interface{}{ + "user": map[string]interface{}{ + "dashboard_display": "dashboard_display", + "layout": "layout", + }, + } + //test + err := testConfig.MergeConfigMap(mergeSettings) + + //verify + require.NoError(t, err) + + // if using Set, the MergeConfigMap functionality will not override + // if using SetDefault, the MergeConfigMap will override correctly + require.Equal(t, "hello", testConfig.GetString("user.dashboard_display")) + require.Equal(t, "layout", testConfig.GetString("user.layout")) + +} diff --git a/webapp/backend/pkg/config/interface.go b/webapp/backend/pkg/config/interface.go index 8f0b773..d041dc2 100644 --- a/webapp/backend/pkg/config/interface.go +++ b/webapp/backend/pkg/config/interface.go @@ -12,12 +12,17 @@ type Interface interface { WriteConfig() error Set(key string, value interface{}) SetDefault(key string, value interface{}) + MergeConfigMap(cfg map[string]interface{}) error + Sub(key string) Interface AllSettings() map[string]interface{} + AllKeys() []string + SubKeys(key string) []string IsSet(key string) bool Get(key string) interface{} GetBool(key string) bool GetInt(key string) int + GetInt64(key string) int64 GetString(key string) string GetStringSlice(key string) []string UnmarshalKey(key string, rawVal interface{}, decoderOpts ...viper.DecoderConfigOption) error diff --git a/webapp/backend/pkg/config/mock/mock_config.go b/webapp/backend/pkg/config/mock/mock_config.go index 8e54f6f..1b61b2c 100644 --- a/webapp/backend/pkg/config/mock/mock_config.go +++ b/webapp/backend/pkg/config/mock/mock_config.go @@ -7,6 +7,7 @@ package mock_config import ( reflect "reflect" + config "github.com/analogj/scrutiny/webapp/backend/pkg/config" gomock "github.com/golang/mock/gomock" viper "github.com/spf13/viper" ) @@ -34,6 +35,20 @@ func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder { return m.recorder } +// AllKeys mocks base method. +func (m *MockInterface) AllKeys() []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AllKeys") + ret0, _ := ret[0].([]string) + return ret0 +} + +// AllKeys indicates an expected call of AllKeys. +func (mr *MockInterfaceMockRecorder) AllKeys() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AllKeys", reflect.TypeOf((*MockInterface)(nil).AllKeys)) +} + // AllSettings mocks base method. func (m *MockInterface) AllSettings() map[string]interface{} { m.ctrl.T.Helper() @@ -90,6 +105,20 @@ func (mr *MockInterfaceMockRecorder) GetInt(key interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInt", reflect.TypeOf((*MockInterface)(nil).GetInt), key) } +// GetInt64 mocks base method. +func (m *MockInterface) GetInt64(key string) int64 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInt64", key) + ret0, _ := ret[0].(int64) + return ret0 +} + +// GetInt64 indicates an expected call of GetInt64. +func (mr *MockInterfaceMockRecorder) GetInt64(key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInt64", reflect.TypeOf((*MockInterface)(nil).GetInt64), key) +} + // GetString mocks base method. func (m *MockInterface) GetString(key string) string { m.ctrl.T.Helper() @@ -146,6 +175,20 @@ func (mr *MockInterfaceMockRecorder) IsSet(key interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsSet", reflect.TypeOf((*MockInterface)(nil).IsSet), key) } +// MergeConfigMap mocks base method. +func (m *MockInterface) MergeConfigMap(cfg map[string]interface{}) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MergeConfigMap", cfg) + ret0, _ := ret[0].(error) + return ret0 +} + +// MergeConfigMap indicates an expected call of MergeConfigMap. +func (mr *MockInterfaceMockRecorder) MergeConfigMap(cfg interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MergeConfigMap", reflect.TypeOf((*MockInterface)(nil).MergeConfigMap), cfg) +} + // ReadConfig mocks base method. func (m *MockInterface) ReadConfig(configFilePath string) error { m.ctrl.T.Helper() @@ -184,6 +227,34 @@ func (mr *MockInterfaceMockRecorder) SetDefault(key, value interface{}) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDefault", reflect.TypeOf((*MockInterface)(nil).SetDefault), key, value) } +// Sub mocks base method. +func (m *MockInterface) Sub(key string) config.Interface { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Sub", key) + ret0, _ := ret[0].(config.Interface) + return ret0 +} + +// Sub indicates an expected call of Sub. +func (mr *MockInterfaceMockRecorder) Sub(key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sub", reflect.TypeOf((*MockInterface)(nil).Sub), key) +} + +// SubKeys mocks base method. +func (m *MockInterface) SubKeys(key string) []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubKeys", key) + ret0, _ := ret[0].([]string) + return ret0 +} + +// SubKeys indicates an expected call of SubKeys. +func (mr *MockInterfaceMockRecorder) SubKeys(key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubKeys", reflect.TypeOf((*MockInterface)(nil).SubKeys), key) +} + // UnmarshalKey mocks base method. func (m *MockInterface) UnmarshalKey(key string, rawVal interface{}, decoderOpts ...viper.DecoderConfigOption) error { m.ctrl.T.Helper() diff --git a/webapp/backend/pkg/constants.go b/webapp/backend/pkg/constants.go index 9535963..a82c9c3 100644 --- a/webapp/backend/pkg/constants.go +++ b/webapp/backend/pkg/constants.go @@ -4,17 +4,11 @@ const DeviceProtocolAta = "ATA" const DeviceProtocolScsi = "SCSI" const DeviceProtocolNvme = "NVMe" -const NotifyFilterAttributesAll = "all" -const NotifyFilterAttributesCritical = "critical" - -const NotifyLevelFail = "fail" -const NotifyLevelFailScrutiny = "fail_scrutiny" -const NotifyLevelFailSmart = "fail_smart" - //go:generate stringer -type=AttributeStatus +// AttributeStatus bitwise flag, 1,2,4,8,16,32,etc type AttributeStatus uint8 + const ( - // AttributeStatusPassed binary, 1,2,4,8,16,32,etc AttributeStatusPassed AttributeStatus = 0 AttributeStatusFailedSmart AttributeStatus = 1 AttributeStatusWarningScrutiny AttributeStatus = 2 @@ -30,9 +24,10 @@ func AttributeStatusToggle(b, flag AttributeStatus) AttributeStatus { return b ^ func AttributeStatusHas(b, flag AttributeStatus) bool { return b&flag != 0 } //go:generate stringer -type=DeviceStatus +// DeviceStatus bitwise flag, 1,2,4,8,16,32,etc type DeviceStatus uint8 + const ( - // DeviceStatusPassed binary, 1,2,4,8,16,32,etc DeviceStatusPassed DeviceStatus = 0 DeviceStatusFailedSmart DeviceStatus = 1 DeviceStatusFailedScrutiny DeviceStatus = 2 @@ -42,3 +37,29 @@ func DeviceStatusSet(b, flag DeviceStatus) DeviceStatus { return b | flag } func DeviceStatusClear(b, flag DeviceStatus) DeviceStatus { return b &^ flag } func DeviceStatusToggle(b, flag DeviceStatus) DeviceStatus { return b ^ flag } func DeviceStatusHas(b, flag DeviceStatus) bool { return b&flag != 0 } + +// Metrics Specific Filtering & Threshold Constants +type MetricsNotifyLevel int64 + +const ( + MetricsNotifyLevelWarn MetricsNotifyLevel = 1 + MetricsNotifyLevelFail MetricsNotifyLevel = 2 +) + +type MetricsStatusFilterAttributes int64 + +const ( + MetricsStatusFilterAttributesAll MetricsStatusFilterAttributes = 0 + MetricsStatusFilterAttributesCritical MetricsStatusFilterAttributes = 1 +) + +// MetricsStatusThreshold bitwise flag, 1,2,4,8,16,32,etc +type MetricsStatusThreshold int64 + +const ( + MetricsStatusThresholdSmart MetricsStatusThreshold = 1 + MetricsStatusThresholdScrutiny MetricsStatusThreshold = 2 + + //shortcut + MetricsStatusThresholdBoth MetricsStatusThreshold = 3 +) diff --git a/webapp/backend/pkg/database/interface.go b/webapp/backend/pkg/database/interface.go index 5ab7084..f140c26 100644 --- a/webapp/backend/pkg/database/interface.go +++ b/webapp/backend/pkg/database/interface.go @@ -11,9 +11,6 @@ import ( type DeviceRepo interface { Close() error - //GetSettings() - //SaveSetting() - RegisterDevice(ctx context.Context, dev models.Device) error GetDevices(ctx context.Context) ([]models.Device, error) UpdateDevice(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (models.Device, error) @@ -28,4 +25,7 @@ type DeviceRepo interface { GetSummary(ctx context.Context) (map[string]*models.DeviceSummary, error) GetSmartTemperatureHistory(ctx context.Context, durationKey string) (map[string][]measurements.SmartTemperature, error) + + LoadSettings(ctx context.Context) (*models.Settings, error) + SaveSettings(ctx context.Context, settings models.Settings) error } diff --git a/webapp/backend/pkg/database/migrations/m20220716214900/setting.go b/webapp/backend/pkg/database/migrations/m20220716214900/setting.go new file mode 100644 index 0000000..9c1f746 --- /dev/null +++ b/webapp/backend/pkg/database/migrations/m20220716214900/setting.go @@ -0,0 +1,17 @@ +package m20220716214900 + +import ( + "gorm.io/gorm" +) + +type Setting struct { + //GORM attributes, see: http://gorm.io/docs/conventions.html + gorm.Model + + SettingKeyName string `json:"setting_key_name"` + SettingKeyDescription string `json:"setting_key_description"` + SettingDataType string `json:"setting_data_type"` + + SettingValueNumeric int `json:"setting_value_numeric"` + SettingValueString string `json:"setting_value_string"` +} diff --git a/webapp/backend/pkg/database/scrutiny_repository_migrations.go b/webapp/backend/pkg/database/scrutiny_repository_migrations.go index bb40add..a6f1b68 100644 --- a/webapp/backend/pkg/database/scrutiny_repository_migrations.go +++ b/webapp/backend/pkg/database/scrutiny_repository_migrations.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "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/m20220503120000" "github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220509170100" + "github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220716214900" "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/measurements" @@ -275,6 +277,71 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error { return tx.Where("wwn = ?", "").Delete(&models.Device{}).Error }, }, + { + ID: "m20220716214900", // add settings table. + Migrate: func(tx *gorm.DB) error { + + // adding the settings table. + err := tx.AutoMigrate(m20220716214900.Setting{}) + if err != nil { + return err + } + //add defaults. + + var defaultSettings = []m20220716214900.Setting{ + { + SettingKeyName: "theme", + SettingKeyDescription: "Frontend theme ('light' | 'dark' | 'system')", + SettingDataType: "string", + SettingValueString: "system", // options: 'light' | 'dark' | 'system' + }, + { + SettingKeyName: "layout", + SettingKeyDescription: "Frontend layout ('material')", + SettingDataType: "string", + SettingValueString: "material", + }, + { + SettingKeyName: "dashboard_display", + SettingKeyDescription: "Frontend device display title ('name' | 'serial_id' | 'uuid' | 'label')", + SettingDataType: "string", + SettingValueString: "name", + }, + { + SettingKeyName: "dashboard_sort", + SettingKeyDescription: "Frontend device sort by ('status' | 'title' | 'age')", + SettingDataType: "string", + SettingValueString: "status", + }, + { + SettingKeyName: "temperature_unit", + SettingKeyDescription: "Frontend temperature unit ('celsius' | 'fahrenheit')", + SettingDataType: "string", + SettingValueString: "celsius", + }, + + { + SettingKeyName: "metrics.notify_level", + SettingKeyDescription: "Determines which device status will cause a notification (fail or warn)", + SettingDataType: "numeric", + SettingValueNumeric: int(pkg.MetricsNotifyLevelFail), // options: 'fail' or 'warn' + }, + { + SettingKeyName: "metrics.status_filter_attributes", + SettingKeyDescription: "Determines which attributes should impact device status", + SettingDataType: "numeric", + SettingValueNumeric: int(pkg.MetricsStatusFilterAttributesAll), // options: 'all' or 'critical' + }, + { + SettingKeyName: "metrics.status_threshold", + SettingKeyDescription: "Determines which threshold should impact device status", + SettingDataType: "numeric", + SettingValueNumeric: int(pkg.MetricsStatusThresholdBoth), // options: 'scrutiny', 'smart', 'both' + }, + } + return tx.Create(&defaultSettings).Error + }, + }, }) if err := m.Migrate(); err != nil { diff --git a/webapp/backend/pkg/database/scrutiny_repository_settings.go b/webapp/backend/pkg/database/scrutiny_repository_settings.go new file mode 100644 index 0000000..918a9f4 --- /dev/null +++ b/webapp/backend/pkg/database/scrutiny_repository_settings.go @@ -0,0 +1,81 @@ +package database + +import ( + "context" + "fmt" + "github.com/analogj/scrutiny/webapp/backend/pkg/config" + "github.com/analogj/scrutiny/webapp/backend/pkg/models" + "github.com/mitchellh/mapstructure" + "strings" +) + +// LoadSettings will retrieve settings from the database, store them in the AppConfig object, and return a Settings struct +func (sr *scrutinyRepository) LoadSettings(ctx context.Context) (*models.Settings, error) { + settingsEntries := []models.SettingEntry{} + if err := sr.gormClient.WithContext(ctx).Find(&settingsEntries).Error; err != nil { + return nil, fmt.Errorf("Could not get settings from DB: %v", err) + } + + // store retrieved settings in the AppConfig obj + for _, settingsEntry := range settingsEntries { + configKey := fmt.Sprintf("%s.%s", config.DB_USER_SETTINGS_SUBKEY, settingsEntry.SettingKeyName) + + if settingsEntry.SettingDataType == "numeric" { + sr.appConfig.SetDefault(configKey, settingsEntry.SettingValueNumeric) + } else if settingsEntry.SettingDataType == "string" { + sr.appConfig.SetDefault(configKey, settingsEntry.SettingValueString) + } + } + + // unmarshal the dbsetting object data to a settings object. + var settings models.Settings + err := sr.appConfig.UnmarshalKey(config.DB_USER_SETTINGS_SUBKEY, &settings) + if err != nil { + return nil, err + } + return &settings, nil +} + +// testing +// curl -d '{"metrics": { "notify_level": 5, "status_filter_attributes": 5, "status_threshold": 5 }}' -H "Content-Type: application/json" -X POST http://localhost:9090/api/settings +// SaveSettings will update settings in AppConfig object, then save the settings to the database. +func (sr *scrutinyRepository) SaveSettings(ctx context.Context, settings models.Settings) error { + //save the entries to the appconfig + settingsMap := &map[string]interface{}{} + err := mapstructure.Decode(settings, &settingsMap) + if err != nil { + return err + } + settingsWrapperMap := map[string]interface{}{} + settingsWrapperMap[config.DB_USER_SETTINGS_SUBKEY] = *settingsMap + err = sr.appConfig.MergeConfigMap(settingsWrapperMap) + if err != nil { + return err + } + sr.logger.Debugf("after merge settings: %v", sr.appConfig.AllSettings()) + //retrieve current settings from the database + settingsEntries := []models.SettingEntry{} + if err := sr.gormClient.WithContext(ctx).Find(&settingsEntries).Error; err != nil { + return fmt.Errorf("Could not get settings from DB: %v", err) + } + + //update settingsEntries + for ndx, settingsEntry := range settingsEntries { + configKey := fmt.Sprintf("%s.%s", config.DB_USER_SETTINGS_SUBKEY, strings.ToLower(settingsEntry.SettingKeyName)) + + if settingsEntry.SettingDataType == "numeric" { + settingsEntries[ndx].SettingValueNumeric = sr.appConfig.GetInt(configKey) + } else if settingsEntry.SettingDataType == "string" { + settingsEntries[ndx].SettingValueString = sr.appConfig.GetString(configKey) + } + + // store in database. + //TODO: this should be `sr.gormClient.Updates(&settingsEntries).Error` + err := sr.gormClient.Model(&models.SettingEntry{}).Where([]uint{settingsEntry.ID}).Select("setting_value_numeric", "setting_value_string").Updates(settingsEntries[ndx]).Error + if err != nil { + return err + } + + } + return nil +} diff --git a/webapp/backend/pkg/models/setting.go b/webapp/backend/pkg/models/setting.go deleted file mode 100644 index d9a1d6b..0000000 --- a/webapp/backend/pkg/models/setting.go +++ /dev/null @@ -1,5 +0,0 @@ -package models - -// Temperature Format -// Date Format -// Device History window diff --git a/webapp/backend/pkg/models/setting_entry.go b/webapp/backend/pkg/models/setting_entry.go new file mode 100644 index 0000000..48d2c4c --- /dev/null +++ b/webapp/backend/pkg/models/setting_entry.go @@ -0,0 +1,22 @@ +package models + +import ( + "gorm.io/gorm" +) + +// SettingEntry matches a setting row in the database +type SettingEntry struct { + //GORM attributes, see: http://gorm.io/docs/conventions.html + gorm.Model + + SettingKeyName string `json:"setting_key_name" gorm:"unique;not null"` + SettingKeyDescription string `json:"setting_key_description"` + SettingDataType string `json:"setting_data_type"` + + SettingValueNumeric int `json:"setting_value_numeric"` + SettingValueString string `json:"setting_value_string"` +} + +func (s SettingEntry) TableName() string { + return "settings" +} diff --git a/webapp/backend/pkg/models/settings.go b/webapp/backend/pkg/models/settings.go new file mode 100644 index 0000000..48ba2d5 --- /dev/null +++ b/webapp/backend/pkg/models/settings.go @@ -0,0 +1,22 @@ +package models + +// Settings is made up of parsed SettingEntry objects retrieved from the database +//type Settings struct { +// MetricsNotifyLevel pkg.MetricsNotifyLevel `json:"metrics.notify.level" mapstructure:"metrics.notify.level"` +// MetricsStatusFilterAttributes pkg.MetricsStatusFilterAttributes `json:"metrics.status.filter_attributes" mapstructure:"metrics.status.filter_attributes"` +// MetricsStatusThreshold pkg.MetricsStatusThreshold `json:"metrics.status.threshold" mapstructure:"metrics.status.threshold"` +//} + +type Settings struct { + Theme string `json:"theme" mapstructure:"theme"` + Layout string `json:"layout" mapstructure:"layout"` + DashboardDisplay string `json:"dashboard_display" mapstructure:"dashboard_display"` + DashboardSort string `json:"dashboard_sort" mapstructure:"dashboard_sort"` + TemperatureUnit string `json:"temperature_unit" mapstructure:"temperature_unit"` + + Metrics struct { + NotifyLevel int `json:"notify_level" mapstructure:"notify_level"` + StatusFilterAttributes int `json:"status_filter_attributes" mapstructure:"status_filter_attributes"` + StatusThreshold int `json:"status_threshold" mapstructure:"status_threshold"` + } `json:"metrics" mapstructure:"metrics"` +} diff --git a/webapp/backend/pkg/notify/notify.go b/webapp/backend/pkg/notify/notify.go index bfc6510..657b007 100644 --- a/webapp/backend/pkg/notify/notify.go +++ b/webapp/backend/pkg/notify/notify.go @@ -29,20 +29,22 @@ const NotifyFailureTypeSmartFailure = "SmartFailure" const NotifyFailureTypeScrutinyFailure = "ScrutinyFailure" // ShouldNotify check if the error Message should be filtered (level mismatch or filtered_attributes) -func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLevel string, notifyFilterAttributes string) bool { +func ShouldNotify(device models.Device, smartAttrs measurements.Smart, statusThreshold pkg.MetricsStatusThreshold, statusFilterAttributes pkg.MetricsStatusFilterAttributes) bool { // 1. check if the device is healthy if device.DeviceStatus == pkg.DeviceStatusPassed { return false } + //TODO: cannot check for warning notifyLevel yet. + // setup constants for comparison var requiredDeviceStatus pkg.DeviceStatus var requiredAttrStatus pkg.AttributeStatus - if notifyLevel == pkg.NotifyLevelFail { + if statusThreshold == pkg.MetricsStatusThresholdBoth { // either scrutiny or smart failures should trigger an email requiredDeviceStatus = pkg.DeviceStatusSet(pkg.DeviceStatusFailedSmart, pkg.DeviceStatusFailedScrutiny) requiredAttrStatus = pkg.AttributeStatusSet(pkg.AttributeStatusFailedSmart, pkg.AttributeStatusFailedScrutiny) - } else if notifyLevel == pkg.NotifyLevelFailSmart { + } else if statusThreshold == pkg.MetricsStatusThresholdSmart { //only smart failures requiredDeviceStatus = pkg.DeviceStatusFailedSmart requiredAttrStatus = pkg.AttributeStatusFailedSmart @@ -53,9 +55,9 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLev // 2. check if the attributes that are failing should be filtered (non-critical) // 3. for any unfiltered attribute, store the failure reason (Smart or Scrutiny) - if notifyFilterAttributes == pkg.NotifyFilterAttributesCritical { + if statusFilterAttributes == pkg.MetricsStatusFilterAttributesCritical { hasFailingCriticalAttr := false - var statusFailingCrtiticalAttr pkg.AttributeStatus + var statusFailingCriticalAttr pkg.AttributeStatus for attrId, attrData := range smartAttrs.Attributes { //find failing attribute @@ -64,7 +66,7 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLev } // merge the status's of all critical attributes - statusFailingCrtiticalAttr = pkg.AttributeStatusSet(statusFailingCrtiticalAttr, attrData.GetStatus()) + statusFailingCriticalAttr = pkg.AttributeStatusSet(statusFailingCriticalAttr, attrData.GetStatus()) //found a failing attribute, see if its critical if device.IsScsi() && thresholds.ScsiMetadata[attrId].Critical { @@ -89,7 +91,7 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLev return false } else { // check if any of the critical attributes have a status that we're looking for - return pkg.AttributeStatusHas(statusFailingCrtiticalAttr, requiredAttrStatus) + return pkg.AttributeStatusHas(statusFailingCriticalAttr, requiredAttrStatus) } } else { diff --git a/webapp/backend/pkg/notify/notify_test.go b/webapp/backend/pkg/notify/notify_test.go index aadb5f9..b891ede 100644 --- a/webapp/backend/pkg/notify/notify_test.go +++ b/webapp/backend/pkg/notify/notify_test.go @@ -15,56 +15,56 @@ func TestShouldNotify_MustSkipPassingDevices(t *testing.T) { DeviceStatus: pkg.DeviceStatusPassed, } smartAttrs := measurements.Smart{} - notifyLevel := pkg.NotifyLevelFail - notifyFilterAttributes := pkg.NotifyFilterAttributesAll + statusThreshold := pkg.MetricsStatusThresholdBoth + notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll //assert - require.False(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes)) + require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) } -func TestShouldNotify_NotifyLevelFail_FailingSmartDevice(t *testing.T) { +func TestShouldNotify_MetricsStatusThresholdBoth_FailingSmartDevice(t *testing.T) { t.Parallel() //setup device := models.Device{ DeviceStatus: pkg.DeviceStatusFailedSmart, } smartAttrs := measurements.Smart{} - notifyLevel := pkg.NotifyLevelFail - notifyFilterAttributes := pkg.NotifyFilterAttributesAll + statusThreshold := pkg.MetricsStatusThresholdBoth + notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll //assert - require.True(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes)) + require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) } -func TestShouldNotify_NotifyLevelFailSmart_FailingSmartDevice(t *testing.T) { +func TestShouldNotify_MetricsStatusThresholdSmart_FailingSmartDevice(t *testing.T) { t.Parallel() //setup device := models.Device{ DeviceStatus: pkg.DeviceStatusFailedSmart, } smartAttrs := measurements.Smart{} - notifyLevel := pkg.NotifyLevelFailSmart - notifyFilterAttributes := pkg.NotifyFilterAttributesAll + statusThreshold := pkg.MetricsStatusThresholdSmart + notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll //assert - require.True(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes)) + require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) } -func TestShouldNotify_NotifyLevelFailScrutiny_FailingSmartDevice(t *testing.T) { +func TestShouldNotify_MetricsStatusThresholdScrutiny_FailingSmartDevice(t *testing.T) { t.Parallel() //setup device := models.Device{ DeviceStatus: pkg.DeviceStatusFailedSmart, } smartAttrs := measurements.Smart{} - notifyLevel := pkg.NotifyLevelFailScrutiny - notifyFilterAttributes := pkg.NotifyFilterAttributesAll + statusThreshold := pkg.MetricsStatusThresholdScrutiny + notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll //assert - require.False(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes)) + require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) } -func TestShouldNotify_NotifyFilterAttributesCritical_WithCriticalAttrs(t *testing.T) { +func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithCriticalAttrs(t *testing.T) { t.Parallel() //setup device := models.Device{ @@ -75,14 +75,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithCriticalAttrs(t *testin Status: pkg.AttributeStatusFailedSmart, }, }} - notifyLevel := pkg.NotifyLevelFail - notifyFilterAttributes := pkg.NotifyFilterAttributesCritical + statusThreshold := pkg.MetricsStatusThresholdBoth + notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical //assert - require.True(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes)) + require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) } -func TestShouldNotify_NotifyFilterAttributesCritical_WithMultipleCriticalAttrs(t *testing.T) { +func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithMultipleCriticalAttrs(t *testing.T) { t.Parallel() //setup device := models.Device{ @@ -96,14 +96,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithMultipleCriticalAttrs(t Status: pkg.AttributeStatusFailedScrutiny, }, }} - notifyLevel := pkg.NotifyLevelFail - notifyFilterAttributes := pkg.NotifyFilterAttributesCritical + statusThreshold := pkg.MetricsStatusThresholdBoth + notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical //assert - require.True(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes)) + require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) } -func TestShouldNotify_NotifyFilterAttributesCritical_WithNoCriticalAttrs(t *testing.T) { +func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoCriticalAttrs(t *testing.T) { t.Parallel() //setup device := models.Device{ @@ -114,14 +114,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithNoCriticalAttrs(t *test Status: pkg.AttributeStatusFailedSmart, }, }} - notifyLevel := pkg.NotifyLevelFail - notifyFilterAttributes := pkg.NotifyFilterAttributesCritical + statusThreshold := pkg.MetricsStatusThresholdBoth + notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical //assert - require.False(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes)) + require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) } -func TestShouldNotify_NotifyFilterAttributesCritical_WithNoFailingCriticalAttrs(t *testing.T) { +func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoFailingCriticalAttrs(t *testing.T) { t.Parallel() //setup device := models.Device{ @@ -132,14 +132,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithNoFailingCriticalAttrs( Status: pkg.AttributeStatusPassed, }, }} - notifyLevel := pkg.NotifyLevelFail - notifyFilterAttributes := pkg.NotifyFilterAttributesCritical + statusThreshold := pkg.MetricsStatusThresholdBoth + notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical //assert - require.False(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes)) + require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) } -func TestShouldNotify_NotifyFilterAttributesCritical_NotifyLevelFailSmart_WithCriticalAttrsFailingScrutiny(t *testing.T) { +func TestShouldNotify_MetricsStatusFilterAttributesCritical_MetricsStatusThresholdSmart_WithCriticalAttrsFailingScrutiny(t *testing.T) { t.Parallel() //setup device := models.Device{ @@ -153,9 +153,9 @@ func TestShouldNotify_NotifyFilterAttributesCritical_NotifyLevelFailSmart_WithCr Status: pkg.AttributeStatusFailedScrutiny, }, }} - notifyLevel := pkg.NotifyLevelFailSmart - notifyFilterAttributes := pkg.NotifyFilterAttributesCritical + statusThreshold := pkg.MetricsStatusThresholdSmart + notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical //assert - require.False(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes)) + require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) } diff --git a/webapp/backend/pkg/web/handler/get_settings.go b/webapp/backend/pkg/web/handler/get_settings.go new file mode 100644 index 0000000..b6969ea --- /dev/null +++ b/webapp/backend/pkg/web/handler/get_settings.go @@ -0,0 +1,25 @@ +package handler + +import ( + "github.com/analogj/scrutiny/webapp/backend/pkg/database" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "net/http" +) + +func GetSettings(c *gin.Context) { + logger := c.MustGet("LOGGER").(logrus.FieldLogger) + deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) + + settings, err := deviceRepo.LoadSettings(c) + if err != nil { + logger.Errorln("An error occurred while retrieving settings", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "settings": settings, + }) +} diff --git a/webapp/backend/pkg/web/handler/save_settings.go b/webapp/backend/pkg/web/handler/save_settings.go new file mode 100644 index 0000000..16de020 --- /dev/null +++ b/webapp/backend/pkg/web/handler/save_settings.go @@ -0,0 +1,34 @@ +package handler + +import ( + "github.com/analogj/scrutiny/webapp/backend/pkg/database" + "github.com/analogj/scrutiny/webapp/backend/pkg/models" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "net/http" +) + +func SaveSettings(c *gin.Context) { + logger := c.MustGet("LOGGER").(logrus.FieldLogger) + deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) + + var settings models.Settings + err := c.BindJSON(&settings) + if err != nil { + logger.Errorln("Cannot parse updated settings", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false}) + return + } + + err = deviceRepo.SaveSettings(c, settings) + if err != nil { + logger.Errorln("An error occurred while saving settings", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "settings": settings, + }) +} diff --git a/webapp/backend/pkg/web/handler/upload_device_metrics.go b/webapp/backend/pkg/web/handler/upload_device_metrics.go index d27f66b..82d5850 100644 --- a/webapp/backend/pkg/web/handler/upload_device_metrics.go +++ b/webapp/backend/pkg/web/handler/upload_device_metrics.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/database" @@ -67,7 +68,12 @@ func UploadDeviceMetrics(c *gin.Context) { } //check for error - if notify.ShouldNotify(updatedDevice, smartData, appConfig.GetString("notify.level"), appConfig.GetString("notify.filter_attributes")) { + if notify.ShouldNotify( + updatedDevice, + smartData, + 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))), + ) { //send notifications liveNotify := notify.New( diff --git a/webapp/backend/pkg/web/middleware/logger.go b/webapp/backend/pkg/web/middleware/logger.go index dc988bb..7568efe 100644 --- a/webapp/backend/pkg/web/middleware/logger.go +++ b/webapp/backend/pkg/web/middleware/logger.go @@ -32,7 +32,7 @@ func LoggerMiddleware(logger logrus.FieldLogger) gin.HandlerFunc { hostname, err := os.Hostname() if err != nil { - hostname = "unknow" + hostname = "unknown" } return func(c *gin.Context) { diff --git a/webapp/backend/pkg/web/middleware/repository.go b/webapp/backend/pkg/web/middleware/repository.go index 3fe58d2..f545a33 100644 --- a/webapp/backend/pkg/web/middleware/repository.go +++ b/webapp/backend/pkg/web/middleware/repository.go @@ -1,6 +1,7 @@ package middleware import ( + "context" "github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/database" "github.com/gin-gonic/gin" @@ -14,6 +15,14 @@ func RepositoryMiddleware(appConfig config.Interface, globalLogger logrus.FieldL panic(err) } + // ensure the settings have been loaded into the app config during startup. + _, err = deviceRepo.LoadSettings(context.Background()) + if err != nil { + panic(err) + } + + //settings.UpdateSettingEntries() + //TODO: determine where we can call defer deviceRepo.Close() return func(c *gin.Context) { c.Set("DEVICE_REPOSITORY", deviceRepo) diff --git a/webapp/backend/pkg/web/server.go b/webapp/backend/pkg/web/server.go index d2a5cf6..0ef8bc8 100644 --- a/webapp/backend/pkg/web/server.go +++ b/webapp/backend/pkg/web/server.go @@ -50,6 +50,8 @@ func (ae *AppEngine) Setup(logger logrus.FieldLogger) *gin.Engine { api.GET("/device/:wwn/details", handler.GetDeviceDetails) //used by Details api.DELETE("/device/:wwn", handler.DeleteDevice) //used by UI to delete device + api.GET("/settings", handler.GetSettings) //used to get settings + api.POST("/settings", handler.SaveSettings) //used to save settings } } diff --git a/webapp/backend/pkg/web/server_test.go b/webapp/backend/pkg/web/server_test.go index b64617c..beea762 100644 --- a/webapp/backend/pkg/web/server_test.go +++ b/webapp/backend/pkg/web/server_test.go @@ -3,7 +3,9 @@ package web_test import ( "bytes" "encoding/json" + "fmt" "github.com/analogj/scrutiny/webapp/backend/pkg" + "github.com/analogj/scrutiny/webapp/backend/pkg/config" mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock" "github.com/analogj/scrutiny/webapp/backend/pkg/models" "github.com/analogj/scrutiny/webapp/backend/pkg/models/collector" @@ -89,6 +91,8 @@ func (suite *ServerTestSuite) TestHealthRoute() { mockCtrl := gomock.NewController(suite.T()) defer mockCtrl.Finish() fakeConfig := mock_config.NewMockInterface(mockCtrl) + fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes() + fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil) fakeConfig.EXPECT().GetString("web.database.location").Return(path.Join(parentPath, "scrutiny_test.db")).AnyTimes() fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes() fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() @@ -130,6 +134,8 @@ func (suite *ServerTestSuite) TestRegisterDevicesRoute() { mockCtrl := gomock.NewController(suite.T()) defer mockCtrl.Finish() fakeConfig := mock_config.NewMockInterface(mockCtrl) + fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes() + fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil) fakeConfig.EXPECT().GetString("web.database.location").Return(path.Join(parentPath, "scrutiny_test.db")).AnyTimes() fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes() fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() @@ -170,6 +176,8 @@ func (suite *ServerTestSuite) TestUploadDeviceMetricsRoute() { mockCtrl := gomock.NewController(suite.T()) defer mockCtrl.Finish() fakeConfig := mock_config.NewMockInterface(mockCtrl) + fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes() + fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil) fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db")) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() @@ -186,8 +194,9 @@ func (suite *ServerTestSuite) TestUploadDeviceMetricsRoute() { } else { fakeConfig.EXPECT().GetString("web.influxdb.host").Return("localhost").AnyTimes() } - fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) - fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth)) ae := web.AppEngine{ Config: fakeConfig, @@ -219,10 +228,13 @@ func (suite *ServerTestSuite) TestPopulateMultiple() { mockCtrl := gomock.NewController(suite.T()) defer mockCtrl.Finish() fakeConfig := mock_config.NewMockInterface(mockCtrl) + fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes() + fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil) //fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return("testdata/scrutiny_test.db") fakeConfig.EXPECT().GetStringSlice("notify.urls").Return([]string{}).AnyTimes() - fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) - fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth)) fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db")) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() @@ -319,6 +331,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_WebhookFailure() { mockCtrl := gomock.NewController(suite.T()) defer mockCtrl.Finish() fakeConfig := mock_config.NewMockInterface(mockCtrl) + fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes() + fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil) fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db")) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() @@ -330,8 +344,9 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_WebhookFailure() { fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"https://unroutable.domain.example.asdfghj"}) - fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) - fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth)) if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions { // when running test suite in github actions, we run an influxdb service as a sidecar. @@ -361,6 +376,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptFailure() { mockCtrl := gomock.NewController(suite.T()) defer mockCtrl.Finish() fakeConfig := mock_config.NewMockInterface(mockCtrl) + fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes() + fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil) fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db")) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() @@ -372,8 +389,9 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptFailure() { fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"script:///missing/path/on/disk"}) - fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) - fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth)) if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions { // when running test suite in github actions, we run an influxdb service as a sidecar. @@ -403,6 +421,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptSuccess() { mockCtrl := gomock.NewController(suite.T()) defer mockCtrl.Finish() fakeConfig := mock_config.NewMockInterface(mockCtrl) + fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes() + fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil) fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db")) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() @@ -414,8 +434,9 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptSuccess() { fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"script:///usr/bin/env"}) - fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) - fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth)) if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions { // when running test suite in github actions, we run an influxdb service as a sidecar. @@ -445,6 +466,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ShoutrrrFailure() { mockCtrl := gomock.NewController(suite.T()) defer mockCtrl.Finish() fakeConfig := mock_config.NewMockInterface(mockCtrl) + fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes() + fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil) fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db")) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() @@ -456,8 +479,9 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ShoutrrrFailure() { fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"discord://invalidtoken@channel"}) - fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) - fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth)) if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions { // when running test suite in github actions, we run an influxdb service as a sidecar. @@ -486,6 +510,8 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() { mockCtrl := gomock.NewController(suite.T()) defer mockCtrl.Finish() fakeConfig := mock_config.NewMockInterface(mockCtrl) + fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes() + fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil) fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db")) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() @@ -497,8 +523,10 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() { fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{}) - fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) - fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll)) + fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth)) + if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions { // when running test suite in github actions, we run an influxdb service as a sidecar. fakeConfig.EXPECT().GetString("web.influxdb.host").Return("influxdb").AnyTimes() diff --git a/webapp/frontend/src/app/app.module.ts b/webapp/frontend/src/app/app.module.ts index 904ee15..8d0d060 100644 --- a/webapp/frontend/src/app/app.module.ts +++ b/webapp/frontend/src/app/app.module.ts @@ -1,22 +1,22 @@ -import { NgModule, enableProdMode } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { ExtraOptions, PreloadAllModules, RouterModule } from '@angular/router'; -import { APP_BASE_HREF } from '@angular/common'; -import { MarkdownModule } from 'ngx-markdown'; -import { TreoModule } from '@treo'; -import { TreoConfigModule } from '@treo/services/config'; -import { TreoMockApiModule } from '@treo/lib/mock-api'; -import { CoreModule } from 'app/core/core.module'; -import { appConfig } from 'app/core/config/app.config'; -import { mockDataServices } from 'app/data/mock'; -import { LayoutModule } from 'app/layout/layout.module'; -import { AppComponent } from 'app/app.component'; -import { appRoutes, getAppBaseHref } from 'app/app.routing'; +import {enableProdMode, NgModule} from '@angular/core'; +import {BrowserModule} from '@angular/platform-browser'; +import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {ExtraOptions, PreloadAllModules, RouterModule} from '@angular/router'; +import {APP_BASE_HREF} from '@angular/common'; +import {MarkdownModule} from 'ngx-markdown'; +import {TreoModule} from '@treo'; +import {ScrutinyConfigModule} from 'app/core/config/scrutiny-config.module'; +import {TreoMockApiModule} from '@treo/lib/mock-api'; +import {CoreModule} from 'app/core/core.module'; +import {appConfig} from 'app/core/config/app.config'; +import {mockDataServices} from 'app/data/mock'; +import {LayoutModule} from 'app/layout/layout.module'; +import {AppComponent} from 'app/app.component'; +import {appRoutes, getAppBaseHref} from 'app/app.routing'; const routerConfig: ExtraOptions = { scrollPositionRestoration: 'enabled', - preloadingStrategy : PreloadAllModules + preloadingStrategy: PreloadAllModules }; let dev = [ @@ -41,7 +41,7 @@ if (process.env.NODE_ENV === 'production') { // Treo & Treo Mock API TreoModule, - TreoConfigModule.forRoot(appConfig), + ScrutinyConfigModule.forRoot(appConfig), ...dev, // Core diff --git a/webapp/frontend/src/app/core/config/app.config.ts b/webapp/frontend/src/app/core/config/app.config.ts index 74143c5..b4a6114 100644 --- a/webapp/frontend/src/app/core/config/app.config.ts +++ b/webapp/frontend/src/app/core/config/app.config.ts @@ -10,19 +10,47 @@ export type DashboardSort = 'status' | 'title' | 'age' export type TemperatureUnit = 'celsius' | 'fahrenheit' + +export enum MetricsNotifyLevel { + Warn = 1, + Fail = 2 +} + +export enum MetricsStatusFilterAttributes { + All = 0, + Critical = 1 +} + +export enum MetricsStatusThreshold { + Smart = 1, + Scrutiny = 2, + + // shortcut + Both = 3 +} + /** * AppConfig interface. Update this interface to strictly type your config * object. */ export interface AppConfig { - theme: Theme; - layout: Layout; + theme?: Theme; + layout?: Layout; // Dashboard options - dashboardDisplay: DashboardDisplay; - dashboardSort: DashboardSort; + dashboard_display?: DashboardDisplay; + dashboard_sort?: DashboardSort; + + temperature_unit?: TemperatureUnit; + + // Settings from Scrutiny API + + metrics?: { + notify_level?: MetricsNotifyLevel + status_filter_attributes?: MetricsStatusFilterAttributes + status_threshold?: MetricsStatusThreshold + } - temperatureUnit: TemperatureUnit; } /** @@ -34,12 +62,17 @@ export interface AppConfig { * "ConfigService". */ export const appConfig: AppConfig = { - theme : 'light', + theme: 'light', layout: 'material', - dashboardDisplay: 'name', - dashboardSort: 'status', + dashboard_display: 'name', + dashboard_sort: 'status', - temperatureUnit: 'celsius', + temperature_unit: 'celsius', + metrics: { + notify_level: MetricsNotifyLevel.Fail, + status_filter_attributes: MetricsStatusFilterAttributes.All, + status_threshold: MetricsStatusThreshold.Both + } }; diff --git a/webapp/frontend/src/app/core/config/scrutiny-config.module.ts b/webapp/frontend/src/app/core/config/scrutiny-config.module.ts new file mode 100644 index 0000000..3dd5bc8 --- /dev/null +++ b/webapp/frontend/src/app/core/config/scrutiny-config.module.ts @@ -0,0 +1,33 @@ +import {ModuleWithProviders, NgModule} from '@angular/core'; +import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service'; +import {TREO_APP_CONFIG} from '@treo/services/config/config.constants'; + +@NgModule() +export class ScrutinyConfigModule { + /** + * Constructor + * + * @param {ScrutinyConfigService} _scrutinyConfigService + */ + constructor( + private _scrutinyConfigService: ScrutinyConfigService + ) { + } + + /** + * forRoot method for setting user configuration + * + * @param config + */ + static forRoot(config: any): ModuleWithProviders { + return { + ngModule: ScrutinyConfigModule, + providers: [ + { + provide: TREO_APP_CONFIG, + useValue: config + } + ] + }; + } +} diff --git a/webapp/frontend/src/app/core/config/scrutiny-config.service.ts b/webapp/frontend/src/app/core/config/scrutiny-config.service.ts new file mode 100644 index 0000000..4c6d7b9 --- /dev/null +++ b/webapp/frontend/src/app/core/config/scrutiny-config.service.ts @@ -0,0 +1,84 @@ +import {Inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {TREO_APP_CONFIG} from '@treo/services/config/config.constants'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {getBasePath} from '../../app.routing'; +import {map, tap} from 'rxjs/operators'; +import {AppConfig} from './app.config'; +import {merge} from 'lodash'; + +@Injectable({ + providedIn: 'root' +}) +export class ScrutinyConfigService { + // Private + private _config: BehaviorSubject; + private _defaultConfig: AppConfig; + + constructor( + private _httpClient: HttpClient, + @Inject(TREO_APP_CONFIG) defaultConfig: AppConfig + ) { + // Set the private defaults + this._defaultConfig = defaultConfig + this._config = new BehaviorSubject(null); + } + + + // ----------------------------------------------------------------------------------------------------- + // @ Accessors + // ----------------------------------------------------------------------------------------------------- + + /** + * Setter & getter for config + */ + set config(value: AppConfig) { + // get the current config, merge the new values, and then submit. (setTheme only sets a single key, not the whole obj) + const mergedSettings = merge({}, this._config.getValue(), value); + + console.log('saving settings...', mergedSettings) + this._httpClient.post(getBasePath() + '/api/settings', mergedSettings).pipe( + map((response: any) => { + console.log('settings resp') + return response.settings + }), + tap((settings: AppConfig) => { + this._config.next(settings); + return settings + }) + ).subscribe(resp => { + console.log('updated settings', resp) + }) + } + + get config$(): Observable { + if (this._config.getValue()) { + console.log('using cached settings:', this._config.getValue()) + return this._config.asObservable() + } else { + console.log('retrieving settings') + return this._httpClient.get(getBasePath() + '/api/settings').pipe( + map((response: any) => { + return response.settings + }), + tap((settings: AppConfig) => { + this._config.next(settings); + return this._config.asObservable() + }) + ); + } + + } + + // ----------------------------------------------------------------------------------------------------- + // @ Public methods + // ----------------------------------------------------------------------------------------------------- + + /** + * Resets the config to the default + */ + reset(): void { + // Set the config + this.config = this._defaultConfig + } +} diff --git a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.html b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.html index e7b0ffd..bd7b4a1 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.html +++ b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.html @@ -1,21 +1,21 @@ -
{{deviceSummary.device | deviceTitle:config.dashboardDisplay}} + class="font-bold text-md text-secondary uppercase tracking-wider">{{deviceSummary.device | deviceTitle:config.dashboard_display}}
Last Updated on {{deviceSummary.smart.collector_date | date:'MMMM dd, yyyy - HH:mm' }}
@@ -46,12 +46,14 @@
Status
-
{{ deviceStatusString(deviceSummary.device.device_status) | titlecase}}
+
{{ deviceStatusString(deviceSummary) | titlecase}}
No Data
Temperature
-
{{ deviceSummary.smart?.temp | temperature:config.temperatureUnit:true }}
+
{{ deviceSummary.smart?.temp | temperature:config.temperature_unit:true }}
--
diff --git a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.spec.ts b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.spec.ts index 7b334bb..2c21c89 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.spec.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.spec.ts @@ -9,13 +9,14 @@ import {MatMenuModule} from '@angular/material/menu'; import {TREO_APP_CONFIG} from '@treo/services/config/config.constants'; import {DeviceSummaryModel} from 'app/core/models/device-summary-model'; import * as moment from 'moment'; +import {HttpClientTestingModule} from '@angular/common/http/testing'; describe('DashboardDeviceComponent', () => { let component: DashboardDeviceComponent; let fixture: ComponentFixture; const matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']); - // const configServiceSpy = jasmine.createSpyObj('TreoConfigService', ['config$']); + // const configServiceSpy = jasmine.createSpyObj('ScrutinyConfigService', ['config$']); beforeEach(async(() => { @@ -25,10 +26,11 @@ describe('DashboardDeviceComponent', () => { MatIconModule, MatMenuModule, SharedModule, + HttpClientTestingModule, ], providers: [ {provide: MatDialog, useValue: matDialogSpy}, - {provide: TREO_APP_CONFIG, useValue: {dashboardDisplay: 'name'}} + {provide: TREO_APP_CONFIG, useValue: {dashboard_display: 'name'}} ], declarations: [DashboardDeviceComponent] }) diff --git a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.ts b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.ts index 4fb7d7a..a8de9d5 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.ts @@ -2,7 +2,7 @@ import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; import * as moment from 'moment'; import {takeUntil} from 'rxjs/operators'; import {AppConfig} from 'app/core/config/app.config'; -import {TreoConfigService} from '@treo/services/config'; +import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service'; import {Subject} from 'rxjs'; import humanizeDuration from 'humanize-duration' import {MatDialog} from '@angular/material/dialog'; @@ -18,7 +18,7 @@ import {DeviceSummaryModel} from 'app/core/models/device-summary-model'; export class DashboardDeviceComponent implements OnInit { constructor( - private _configService: TreoConfigService, + private _configService: ScrutinyConfigService, public dialog: MatDialog, ) { // Set the private defaults @@ -68,7 +68,15 @@ export class DashboardDeviceComponent implements OnInit { } } - deviceStatusString(deviceStatus: number): string { + deviceStatusString(deviceSummary: DeviceSummaryModel): string { + // no smart data, so treat the device status as unknown + if (!deviceSummary.smart) { + return 'unknown' + } + + // determine the device status, by comparing it against the allowed threshold + // tslint:disable-next-line:no-bitwise + const deviceStatus = deviceSummary.device.device_status & this.config.metrics.status_threshold if (deviceStatus === 0) { return 'passed' } else { @@ -82,7 +90,7 @@ export class DashboardDeviceComponent implements OnInit { // width: '250px', data: { wwn: this.deviceWWN, - title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboardDisplay) + title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboard_display) } }); diff --git a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.html b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.html index a10d550..cde830a 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.html +++ b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.html @@ -43,63 +43,27 @@ Fahrenheit -
-
- - - -
- - Critical Error Threshold - - - - Critical Warning Threshold - - -
- -
- - Error Threshold - - - - Warning Threshold - - -
- -
- - -
- - Critical Error Threshold - - - - Critical Warning Threshold - - -
+
+ + Device Status - Thresholds + + Smart + Scrutiny + Both + + +
-
- -
- - Critical Error Threshold - - - - Critical Warning Threshold - - -
-
-
+
+ + Notify - Filter Attributes + + All + Critical + +
diff --git a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts index 70a0978..21e8e8e 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts @@ -1,6 +1,14 @@ import {Component, OnInit} from '@angular/core'; -import {AppConfig} from 'app/core/config/app.config'; -import {TreoConfigService} from '@treo/services/config'; +import { + AppConfig, + DashboardDisplay, + DashboardSort, + MetricsStatusFilterAttributes, + MetricsStatusThreshold, + TemperatureUnit, + Theme +} from 'app/core/config/app.config'; +import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service'; import {Subject} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; @@ -15,12 +23,14 @@ export class DashboardSettingsComponent implements OnInit { dashboardSort: string; temperatureUnit: string; theme: string; + statusThreshold: number; + statusFilterAttributes: number; // Private private _unsubscribeAll: Subject; constructor( - private _configService: TreoConfigService, + private _configService: ScrutinyConfigService, ) { // Set the private defaults this._unsubscribeAll = new Subject(); @@ -33,21 +43,28 @@ export class DashboardSettingsComponent implements OnInit { .subscribe((config: AppConfig) => { // Store the config - this.dashboardDisplay = config.dashboardDisplay; - this.dashboardSort = config.dashboardSort; - this.temperatureUnit = config.temperatureUnit; + this.dashboardDisplay = config.dashboard_display; + this.dashboardSort = config.dashboard_sort; + this.temperatureUnit = config.temperature_unit; this.theme = config.theme; + this.statusFilterAttributes = config.metrics.status_filter_attributes; + this.statusThreshold = config.metrics.status_threshold; + }); } saveSettings(): void { - const newSettings = { - dashboardDisplay: this.dashboardDisplay, - dashboardSort: this.dashboardSort, - temperatureUnit: this.temperatureUnit, - theme: this.theme + const newSettings: AppConfig = { + dashboard_display: this.dashboardDisplay as DashboardDisplay, + dashboard_sort: this.dashboardSort as DashboardSort, + temperature_unit: this.temperatureUnit as TemperatureUnit, + theme: this.theme as Theme, + metrics: { + status_filter_attributes: this.statusFilterAttributes as MetricsStatusFilterAttributes, + status_threshold: this.statusThreshold as MetricsStatusThreshold + } } this._configService.config = newSettings console.log(`Saved Settings: ${JSON.stringify(newSettings)}`) diff --git a/webapp/frontend/src/app/layout/layout.component.ts b/webapp/frontend/src/app/layout/layout.component.ts index 6a3a68b..8e567a6 100644 --- a/webapp/frontend/src/app/layout/layout.component.ts +++ b/webapp/frontend/src/app/layout/layout.component.ts @@ -1,22 +1,21 @@ -import { Component, Inject, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; -import { DOCUMENT } from '@angular/common'; -import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; -import { MatSlideToggleChange } from '@angular/material/slide-toggle'; -import { Subject } from 'rxjs'; -import { filter, takeUntil } from 'rxjs/operators'; -import { TreoConfigService } from '@treo/services/config'; -import { TreoDrawerService } from '@treo/components/drawer'; -import { Layout } from 'app/layout/layout.types'; -import { AppConfig, Theme } from 'app/core/config/app.config'; +import {Component, Inject, OnDestroy, OnInit, ViewEncapsulation} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {ActivatedRoute, NavigationEnd, Router} from '@angular/router'; +import {MatSlideToggleChange} from '@angular/material/slide-toggle'; +import {Subject} from 'rxjs'; +import {filter, takeUntil} from 'rxjs/operators'; +import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service'; +import {TreoDrawerService} from '@treo/components/drawer'; +import {Layout} from 'app/layout/layout.types'; +import {AppConfig, Theme} from 'app/core/config/app.config'; @Component({ - selector : 'layout', - templateUrl : './layout.component.html', - styleUrls : ['./layout.component.scss'], + selector: 'layout', + templateUrl: './layout.component.html', + styleUrls: ['./layout.component.scss'], encapsulation: ViewEncapsulation.None }) -export class LayoutComponent implements OnInit, OnDestroy -{ +export class LayoutComponent implements OnInit, OnDestroy { config: AppConfig; layout: Layout; theme: Theme; @@ -29,14 +28,14 @@ export class LayoutComponent implements OnInit, OnDestroy * Constructor * * @param {ActivatedRoute} _activatedRoute - * @param {TreoConfigService} _treoConfigService + * @param {ScrutinyConfigService} _scrutinyConfigService * @param {TreoDrawerService} _treoDrawerService * @param {DOCUMENT} _document * @param {Router} _router */ constructor( private _activatedRoute: ActivatedRoute, - private _treoConfigService: TreoConfigService, + private _scrutinyConfigService: ScrutinyConfigService, private _treoDrawerService: TreoDrawerService, @Inject(DOCUMENT) private _document: any, private _router: Router @@ -59,7 +58,7 @@ export class LayoutComponent implements OnInit, OnDestroy ngOnInit(): void { // Subscribe to config changes - this._treoConfigService.config$ + this._scrutinyConfigService.config$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe((config: AppConfig) => { @@ -180,18 +179,17 @@ export class LayoutComponent implements OnInit, OnDestroy * * @param layout */ - setLayout(layout: string): void - { + setLayout(layout: Layout): void { // Clear the 'layout' query param to allow layout changes this._router.navigate([], { - queryParams : { + queryParams: { layout: null }, queryParamsHandling: 'merge' }).then(() => { // Set the config - this._treoConfigService.config = {layout}; + this._scrutinyConfigService.config = {layout}; }); } @@ -202,6 +200,6 @@ export class LayoutComponent implements OnInit, OnDestroy */ setTheme(change: MatSlideToggleChange): void { - this._treoConfigService.config = {theme: change.checked ? 'dark' : 'light'}; + this._scrutinyConfigService.config = {theme: change.checked ? 'dark' : 'light'}; } } diff --git a/webapp/frontend/src/app/modules/dashboard/dashboard.component.html b/webapp/frontend/src/app/modules/dashboard/dashboard.component.html index d370ab5..f106131 100644 --- a/webapp/frontend/src/app/modules/dashboard/dashboard.component.html +++ b/webapp/frontend/src/app/modules/dashboard/dashboard.component.html @@ -51,7 +51,11 @@

{{hostId.key}}

- +
diff --git a/webapp/frontend/src/app/modules/dashboard/dashboard.component.ts b/webapp/frontend/src/app/modules/dashboard/dashboard.component.ts index 7352e98..f790891 100644 --- a/webapp/frontend/src/app/modules/dashboard/dashboard.component.ts +++ b/webapp/frontend/src/app/modules/dashboard/dashboard.component.ts @@ -14,7 +14,7 @@ import {DashboardService} from 'app/modules/dashboard/dashboard.service'; import {MatDialog} from '@angular/material/dialog'; import {DashboardSettingsComponent} from 'app/layout/common/dashboard-settings/dashboard-settings.component'; import {AppConfig} from 'app/core/config/app.config'; -import {TreoConfigService} from '@treo/services/config'; +import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service'; import {Router} from '@angular/router'; import {TemperaturePipe} from 'app/shared/temperature.pipe'; import {DeviceTitlePipe} from 'app/shared/device-title.pipe'; @@ -43,13 +43,13 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy * Constructor * * @param {DashboardService} _dashboardService - * @param {TreoConfigService} _configService + * @param {ScrutinyConfigService} _configService * @param {MatDialog} dialog * @param {Router} router */ constructor( private _dashboardService: DashboardService, - private _configService: TreoConfigService, + private _configService: ScrutinyConfigService, public dialog: MatDialog, private router: Router, ) @@ -150,7 +150,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy continue } - const deviceName = DeviceTitlePipe.deviceTitleWithFallback(deviceSummary.device, this.config.dashboardDisplay) + const deviceName = DeviceTitlePipe.deviceTitleWithFallback(deviceSummary.device, this.config.dashboard_display) const deviceSeriesMetadata = { name: deviceName, @@ -161,7 +161,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy const newDate = new Date(tempHistory.date); deviceSeriesMetadata.data.push({ x: newDate, - y: TemperaturePipe.formatTemperature(tempHistory.temp, this.config.temperatureUnit, false) + y: TemperaturePipe.formatTemperature(tempHistory.temp, this.config.temperature_unit, false) }) } deviceTemperatureSeries.push(deviceSeriesMetadata) @@ -212,7 +212,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy y : { formatter: (value) => { - return TemperaturePipe.formatTemperature(value, this.config.temperatureUnit, true) as string; + return TemperaturePipe.formatTemperature(value, this.config.temperature_unit, true) as string; } } }, diff --git a/webapp/frontend/src/app/modules/detail/detail.component.html b/webapp/frontend/src/app/modules/detail/detail.component.html index da8ad7a..e96a493 100644 --- a/webapp/frontend/src/app/modules/detail/detail.component.html +++ b/webapp/frontend/src/app/modules/detail/detail.component.html @@ -4,7 +4,7 @@
-

Drive Details - {{device | deviceTitle:config.dashboardDisplay}}

+

Drive Details - {{device | deviceTitle:config.dashboard_display}}

Dive into S.M.A.R.T data
@@ -126,7 +126,7 @@
Powered On
-
{{smart_results[0]?.temp | temperature:config.temperatureUnit:true}}
+
{{smart_results[0]?.temp | temperature:config.temperature_unit:true}}
Temperature
diff --git a/webapp/frontend/src/app/modules/detail/detail.component.ts b/webapp/frontend/src/app/modules/detail/detail.component.ts index 0e29652..1353c51 100644 --- a/webapp/frontend/src/app/modules/detail/detail.component.ts +++ b/webapp/frontend/src/app/modules/detail/detail.component.ts @@ -8,7 +8,7 @@ import {MatDialog} from '@angular/material/dialog'; import {MatSort} from '@angular/material/sort'; import {MatTableDataSource} from '@angular/material/table'; import {Subject} from 'rxjs'; -import {TreoConfigService} from '@treo/services/config'; +import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service'; import {animate, state, style, transition, trigger} from '@angular/animations'; import {formatDate} from '@angular/common'; import {takeUntil} from 'rxjs/operators'; @@ -44,13 +44,13 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { * * @param {DetailService} _detailService * @param {MatDialog} dialog - * @param {TreoConfigService} _configService + * @param {ScrutinyConfigService} _configService * @param {string} locale */ constructor( private _detailService: DetailService, public dialog: MatDialog, - private _configService: TreoConfigService, + private _configService: ScrutinyConfigService, @Inject(LOCALE_ID) public locale: string ) { // Set the private defaults