diff --git a/README.md b/README.md index 1360f49d..c5e359fc 100644 --- a/README.md +++ b/README.md @@ -239,9 +239,9 @@ scrutiny-collector-metrics run --debug --log-file /tmp/collector.log | linux-arm-6 | :white_check_mark: | | | linux-arm-7 | :white_check_mark: | web/collector only. see [#236](https://github.com/AnalogJ/scrutiny/issues/236) | | linux-arm64 | :white_check_mark: | :white_check_mark: | -| freebsd-amd64 | collector only. see [#238](https://github.com/AnalogJ/scrutiny/issues/238) | | -| macos-amd64 | | :white_check_mark: | -| macos-arm64 | | :white_check_mark: | +| freebsd-amd64 | :white_check_mark: | | +| macos-amd64 | :white_check_mark: | :white_check_mark: | +| macos-arm64 | :white_check_mark: | :white_check_mark: | | windows-amd64 | :white_check_mark: | WIP, see [#15](https://github.com/AnalogJ/scrutiny/issues/15) | | windows-arm64 | :white_check_mark: | | diff --git a/collector/cmd/collector-metrics/collector-metrics.go b/collector/cmd/collector-metrics/collector-metrics.go index a170f16e..39d0a491 100644 --- a/collector/cmd/collector-metrics/collector-metrics.go +++ b/collector/cmd/collector-metrics/collector-metrics.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "github.com/analogj/scrutiny/collector/pkg/collector" "github.com/analogj/scrutiny/collector/pkg/config" @@ -120,26 +121,16 @@ OPTIONS: config.Set("api.endpoint", apiEndpoint) } - collectorLogger := logrus.WithFields(logrus.Fields{ - "type": "metrics", - }) - - if level, err := logrus.ParseLevel(config.GetString("log.level")); err == nil { - logrus.SetLevel(level) - } else { - logrus.SetLevel(logrus.InfoLevel) - } - - if config.IsSet("log.file") && len(config.GetString("log.file")) > 0 { - logFile, err := os.OpenFile(config.GetString("log.file"), os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - logrus.Errorf("Failed to open log file %s for output: %s", config.GetString("log.file"), err) - return err - } + collectorLogger, logFile, err := CreateLogger(config) + if logFile != nil { defer logFile.Close() - logrus.SetOutput(io.MultiWriter(os.Stderr, logFile)) + } + if err != nil { + return err } + settingsData, err := json.MarshalIndent(config.AllSettings(), "", "\t") + collectorLogger.Debug(string(settingsData), err) metricCollector, err := collector.CreateMetricsCollector( config, collectorLogger, @@ -192,5 +183,28 @@ OPTIONS: if err != nil { log.Fatal(color.HiRedString("ERROR: %v", err)) } +} + +func CreateLogger(appConfig config.Interface) (*logrus.Entry, *os.File, error) { + logger := logrus.WithFields(logrus.Fields{ + "type": "metrics", + }) + + if level, err := logrus.ParseLevel(appConfig.GetString("log.level")); err == nil { + logger.Logger.SetLevel(level) + } else { + logger.Logger.SetLevel(logrus.InfoLevel) + } + var logFile *os.File + var err error + if appConfig.IsSet("log.file") && len(appConfig.GetString("log.file")) > 0 { + logFile, err = os.OpenFile(appConfig.GetString("log.file"), os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + logger.Logger.Errorf("Failed to open log file %s for output: %s", appConfig.GetString("log.file"), err) + return nil, logFile, err + } + logger.Logger.SetOutput(io.MultiWriter(os.Stderr, logFile)) + } + return logger, logFile, nil } diff --git a/docker/Dockerfile.collector b/docker/Dockerfile.collector index c4553fd6..2839bdfe 100644 --- a/docker/Dockerfile.collector +++ b/docker/Dockerfile.collector @@ -14,7 +14,7 @@ RUN make binary-clean binary-collector ######## FROM debian:bullseye-slim as runtime -WORKDIR /scrutiny +WORKDIR /opt/scrutiny ENV PATH="/opt/scrutiny/bin:${PATH}" RUN apt-get update && apt-get install -y cron smartmontools ca-certificates tzdata && update-ca-certificates 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/INSTALL_SYNOLOGY_COLLECTOR.md b/docs/INSTALL_SYNOLOGY_COLLECTOR.md index acd80d05..033dcbac 100644 --- a/docs/INSTALL_SYNOLOGY_COLLECTOR.md +++ b/docs/INSTALL_SYNOLOGY_COLLECTOR.md @@ -91,9 +91,13 @@ wget https://raw.githubusercontent.com/smartmontools/smartmontools/master/smartm ``` #!/bin/bash -/volume1/\@Entware/scrutiny/bin/scrutiny-collector-metrics-linux-arm64 run --config /volume1/\@Entware/scrutiny/config/collector.yaml +/volume1/\@Entware/scrutiny/bin/scrutiny-collector-metrics-linux-arm64 run --config /volume1/\@Entware/scrutiny/conf/collector.yaml ``` +**Make `run_collect.sh` executable** + +`chmod +x /volume1/\@Entware/scrutiny/bin/run_collect.sh` + ## Set up Synology to run a scheduled task. Log in to DSM and do the following: @@ -131,4 +135,4 @@ Frequency: ## Troubleshooting -If you have any issues with your devices being detected, or incorrect data, please take a look at [TROUBLESHOOTING_DEVICE_COLLECTOR.md](./TROUBLESHOOTING_DEVICE_COLLECTOR.md) \ No newline at end of file +If you have any issues with your devices being detected, or incorrect data, please take a look at [TROUBLESHOOTING_DEVICE_COLLECTOR.md](./TROUBLESHOOTING_DEVICE_COLLECTOR.md) diff --git a/docs/TROUBLESHOOTING_INFLUXDB.md b/docs/TROUBLESHOOTING_INFLUXDB.md index 22b52c6c..faa9d4d8 100644 --- a/docs/TROUBLESHOOTING_INFLUXDB.md +++ b/docs/TROUBLESHOOTING_INFLUXDB.md @@ -1,7 +1,19 @@ # InfluxDB Troubleshooting -## Installation -InfluxDB is a required dependency for Scrutiny v0.4.0+. +## Why?? + +Scrutiny has many features, but the relevant one to this conversation is the "S.M.A.R.T metric tracking for historical +trends". Basically Scrutiny not only shows you the current SMART values, but how they've changed over weeks, months (or +even years). + +To efficiently handle that data at scale (and to make my life easier as a developer) I decided to add InfluxDB as a +dependency. It's a dedicated timeseries database, as opposed to the general purpose sqlite DB I used before. I also did +a bunch of testing and analysis before I made the change. With InfluxDB the memory footprint for Scrutiny (at idle) is ~ +100mb, which is still fairly reasonable. + +## Installation + +InfluxDB is a required dependency for Scrutiny v0.4.0+. https://docs.influxdata.com/influxdb/v2.2/install/ diff --git a/docs/TROUBLESHOOTING_NOTIFICATIONS.md b/docs/TROUBLESHOOTING_NOTIFICATIONS.md index 2baba6a3..2f2abee0 100644 --- a/docs/TROUBLESHOOTING_NOTIFICATIONS.md +++ b/docs/TROUBLESHOOTING_NOTIFICATIONS.md @@ -21,5 +21,6 @@ SCRUTINY_DEVICE_NAME - eg. /dev/sda SCRUTINY_DEVICE_TYPE - ATA/SCSI/NVMe SCRUTINY_DEVICE_SERIAL - eg. WDDJ324KSO SCRUTINY_MESSAGE - eg. "Scrutiny SMART error notification for device: %s\nFailure Type: %s\nDevice Name: %s\nDevice Serial: %s\nDevice Type: %s\nDate: %s" +SCRUTINY_HOST_ID - (optional) eg. "my-custom-host-id" ``` diff --git a/docs/TROUBLESHOOTING_UDEV.md b/docs/TROUBLESHOOTING_UDEV.md new file mode 100644 index 00000000..89dce0d6 --- /dev/null +++ b/docs/TROUBLESHOOTING_UDEV.md @@ -0,0 +1,18 @@ +# Operating systems without udev + +Some operating systems do not come with `udev` out of the box, for example Alpine Linux. In these instances you will not be able to bind `/run/udev` to the container for sharing device metadata. Some operating systems offer `udev` as a package that can be installed separately, or an alternative (such as `eudev` in the case of Alpine Linux) that provides the same functionality. + +To install `eudev` in Alpine Linux (run as root): + +``` +apk add eudev +setup-udev +``` + +Once your `udev` implementation is installed, create `/run/udev` with the following command: + +``` +udevadm trigger +``` + +On Alpine Linux, this also has the benefit of creating symlinks to device serial numbers in `/dev/disk/by-id`. diff --git a/docs/dbdiagram.io.txt b/docs/dbdiagram.io.txt index 7d23af7c..23265ad2 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/example.scrutiny.yaml b/example.scrutiny.yaml index b73b7115..c93725eb 100644 --- a/example.scrutiny.yaml +++ b/example.scrutiny.yaml @@ -73,8 +73,6 @@ log: # - "join://shoutrrr:api-key@join/?devices=device1[,device2, ...][&icon=icon][&title=title]" # - "script:///file/path/on/disk" # - "https://www.example.com/path" -# filter_attributes: 'all' # options: 'all' or 'critical' -# level: 'fail' # options: 'fail', 'fail_scrutiny', 'fail_smart' ######################################################################################################################## # FEATURES COMING SOON diff --git a/webapp/backend/cmd/scrutiny/scrutiny.go b/webapp/backend/cmd/scrutiny/scrutiny.go index 103fd6b1..ae226777 100644 --- a/webapp/backend/cmd/scrutiny/scrutiny.go +++ b/webapp/backend/cmd/scrutiny/scrutiny.go @@ -1,12 +1,15 @@ package main import ( + "encoding/json" "fmt" "github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/errors" "github.com/analogj/scrutiny/webapp/backend/pkg/version" "github.com/analogj/scrutiny/webapp/backend/pkg/web" - log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus" + "io" + "log" "os" "time" @@ -107,7 +110,18 @@ OPTIONS: config.Set("log.file", c.String("log-file")) } - webServer := web.AppEngine{Config: config} + webLogger, logFile, err := CreateLogger(config) + if logFile != nil { + defer logFile.Close() + } + if err != nil { + return err + } + + settingsData, err := json.Marshal(config.AllSettings()) + webLogger.Debug(string(settingsData), err) + + webServer := web.AppEngine{Config: config, Logger: webLogger} return webServer.Start() }, @@ -140,3 +154,27 @@ OPTIONS: } } + +func CreateLogger(appConfig config.Interface) (*logrus.Entry, *os.File, error) { + logger := logrus.WithFields(logrus.Fields{ + "type": "web", + }) + //set default log level + if level, err := logrus.ParseLevel(appConfig.GetString("log.level")); err == nil { + logger.Logger.SetLevel(level) + } else { + logger.Logger.SetLevel(logrus.InfoLevel) + } + + var logFile *os.File + var err error + if appConfig.IsSet("log.file") && len(appConfig.GetString("log.file")) > 0 { + logFile, err = os.OpenFile(appConfig.GetString("log.file"), os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + logger.Logger.Errorf("Failed to open log file %s for output: %s", appConfig.GetString("log.file"), err) + return nil, logFile, err + } + logger.Logger.SetOutput(io.MultiWriter(os.Stderr, logFile)) + } + return logger, logFile, nil +} diff --git a/webapp/backend/pkg/config/config.go b/webapp/backend/pkg/config/config.go index 5f5d4ccd..39613736 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 00000000..f734d506 --- /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 8f0b773d..d041dc22 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 8e54f6fe..1b61b2cb 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 9535963c..a82c9c35 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 5ab7084f..f140c269 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 00000000..70d8d5e7 --- /dev/null +++ b/webapp/backend/pkg/database/migrations/m20220716214900/setting.go @@ -0,0 +1,18 @@ +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"` + SettingValueBool bool `json:"setting_value_bool"` +} diff --git a/webapp/backend/pkg/database/scrutiny_repository.go b/webapp/backend/pkg/database/scrutiny_repository.go index 81f2316e..521ba7d0 100644 --- a/webapp/backend/pkg/database/scrutiny_repository.go +++ b/webapp/backend/pkg/database/scrutiny_repository.go @@ -62,7 +62,20 @@ func NewScrutinyRepository(appConfig config.Interface, globalLogger logrus.Field // Gorm/SQLite setup //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// globalLogger.Infof("Trying to connect to scrutiny sqlite db: %s\n", appConfig.GetString("web.database.location")) - database, err := gorm.Open(sqlite.Open(appConfig.GetString("web.database.location")), &gorm.Config{ + + // When a transaction cannot lock the database, because it is already locked by another one, + // SQLite by default throws an error: database is locked. This behavior is usually not appropriate when + // concurrent access is needed, typically when multiple processes write to the same database. + // PRAGMA busy_timeout lets you set a timeout or a handler for these events. When setting a timeout, + // SQLite will try the transaction multiple times within this timeout. + // fixes #341 + // https://rsqlite.r-dbi.org/reference/sqlitesetbusyhandler + // retrying for 30000 milliseconds, 30seconds - this would be unreasonable for a distributed multi-tenant application, + // but should be fine for local usage. + pragmaStr := sqlitePragmaString(map[string]string{ + "busy_timeout": "30000", + }) + database, err := gorm.Open(sqlite.Open(appConfig.GetString("web.database.location")+pragmaStr), &gorm.Config{ //TODO: figure out how to log database queries again. //Logger: logger DisableForeignKeyConstraintWhenMigrating: true, @@ -450,3 +463,16 @@ func (sr *scrutinyRepository) lookupNestedDurationKeys(durationKey string) []str } return []string{DURATION_KEY_WEEK} } + +func sqlitePragmaString(pragmas map[string]string) string { + q := url.Values{} + for key, val := range pragmas { + q.Add("_pragma", key+"="+val) + } + + queryStr := q.Encode() + if len(queryStr) > 0 { + return "?" + queryStr + } + return "" +} diff --git a/webapp/backend/pkg/database/scrutiny_repository_migrations.go b/webapp/backend/pkg/database/scrutiny_repository_migrations.go index bb40add9..015428cc 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,77 @@ 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: "file_size_si_units", + SettingKeyDescription: "File size in SI units (true | false)", + SettingDataType: "bool", + SettingValueBool: false, + }, + + { + 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 { @@ -282,6 +355,30 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error { return err } sr.logger.Infoln("Database migration completed successfully") + + //these migrations cannot be done within a transaction, so they are done as a separate group, with `UseTransaction = false` + sr.logger.Infoln("SQLite global configuration migrations starting. Please wait....") + globalMigrateOptions := gormigrate.DefaultOptions + globalMigrateOptions.UseTransaction = false + gm := gormigrate.New(sr.gormClient, globalMigrateOptions, []*gormigrate.Migration{ + { + ID: "g20220802211500", + Migrate: func(tx *gorm.DB) error { + //shrink the Database (maybe necessary after 20220503113100) + if err := tx.Exec("VACUUM;").Error; err != nil { + return err + } + return nil + }, + }, + }) + + if err := gm.Migrate(); err != nil { + sr.logger.Errorf("SQLite global configuration migrations failed with error. \n Please open a github issue at https://github.com/AnalogJ/scrutiny and attach a copy of your scrutiny.db file. \n %v", err) + return err + } + sr.logger.Infoln("SQLite global configuration migrations completed successfully") + return 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 00000000..d92ce9b4 --- /dev/null +++ b/webapp/backend/pkg/database/scrutiny_repository_settings.go @@ -0,0 +1,85 @@ +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) + } else if settingsEntry.SettingDataType == "bool" { + sr.appConfig.SetDefault(configKey, settingsEntry.SettingValueBool) + } + } + + // 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) + } else if settingsEntry.SettingDataType == "bool" { + settingsEntries[ndx].SettingValueBool = sr.appConfig.GetBool(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", "setting_value_bool").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 d9a1d6b8..00000000 --- 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 00000000..2ac78f6b --- /dev/null +++ b/webapp/backend/pkg/models/setting_entry.go @@ -0,0 +1,23 @@ +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"` + SettingValueBool bool `json:"setting_value_bool"` +} + +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 00000000..f06db846 --- /dev/null +++ b/webapp/backend/pkg/models/settings.go @@ -0,0 +1,23 @@ +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"` + FileSizeSIUnits bool `json:"file_size_si_units" mapstructure:"file_size_si_units"` + + 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 bfc65107..3dbe6611 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 { @@ -99,12 +101,13 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLev } } -// TODO: include host and/or user label for device. +// TODO: include user label for device. type Payload struct { - DeviceType string `json:"device_type"` //ATA/SCSI/NVMe - DeviceName string `json:"device_name"` //dev/sda - DeviceSerial string `json:"device_serial"` //WDDJ324KSO - Test bool `json:"test"` // false + HostId string `json:"host_id,omitempty"` //host id (optional) + DeviceType string `json:"device_type"` //ATA/SCSI/NVMe + DeviceName string `json:"device_name"` //dev/sda + DeviceSerial string `json:"device_serial"` //WDDJ324KSO + Test bool `json:"test"` // false //private, populated during init (marked as Public for JSON serialization) Date string `json:"date"` //populated by Send function. @@ -113,8 +116,9 @@ type Payload struct { Message string `json:"message"` } -func NewPayload(device models.Device, test bool) Payload { +func NewPayload(device models.Device, test bool, currentTime ...time.Time) Payload { payload := Payload{ + HostId: strings.TrimSpace(device.HostId), DeviceType: device.DeviceType, DeviceName: device.DeviceName, DeviceSerial: device.SerialNumber, @@ -122,7 +126,13 @@ func NewPayload(device models.Device, test bool) Payload { } //validate that the Payload is populated - sendDate := time.Now() + var sendDate time.Time + if currentTime != nil && len(currentTime) > 0 { + sendDate = currentTime[0] + } else { + sendDate = time.Now() + } + payload.Date = sendDate.Format(time.RFC3339) payload.FailureType = payload.GenerateFailureType(device.DeviceStatus) payload.Subject = payload.GenerateSubject() @@ -146,25 +156,39 @@ func (p *Payload) GenerateFailureType(deviceStatus pkg.DeviceStatus) string { func (p *Payload) GenerateSubject() string { //generate a detailed failure message - return fmt.Sprintf("Scrutiny SMART error (%s) detected on device: %s", p.FailureType, p.DeviceName) + var subject string + if len(p.HostId) > 0 { + subject = fmt.Sprintf("Scrutiny SMART error (%s) detected on [host]device: [%s]%s", p.FailureType, p.HostId, p.DeviceName) + } else { + subject = fmt.Sprintf("Scrutiny SMART error (%s) detected on device: %s", p.FailureType, p.DeviceName) + } + return subject } func (p *Payload) GenerateMessage() string { //generate a detailed failure message - message := fmt.Sprintf( - `Scrutiny SMART error notification for device: %s -Failure Type: %s -Device Name: %s -Device Serial: %s -Device Type: %s -Date: %s`, p.DeviceName, p.FailureType, p.DeviceName, p.DeviceSerial, p.DeviceType, p.Date) + messageParts := []string{} + + messageParts = append(messageParts, fmt.Sprintf("Scrutiny SMART error notification for device: %s", p.DeviceName)) + if len(p.HostId) > 0 { + messageParts = append(messageParts, fmt.Sprintf("Host Id: %s", p.HostId)) + } + + messageParts = append(messageParts, + fmt.Sprintf("Failure Type: %s", p.FailureType), + fmt.Sprintf("Device Name: %s", p.DeviceName), + fmt.Sprintf("Device Serial: %s", p.DeviceSerial), + fmt.Sprintf("Device Type: %s", p.DeviceType), + "", + fmt.Sprintf("Date: %s", p.Date), + ) if p.Test { - message = "TEST NOTIFICATION:\n" + message + messageParts = append([]string{"TEST NOTIFICATION:"}, messageParts...) } - return message + return strings.Join(messageParts, "\n") } func New(logger logrus.FieldLogger, appconfig config.Interface, device models.Device, test bool) Notify { @@ -285,6 +309,9 @@ func (n *Notify) SendScriptNotification(scriptUrl string) error { copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_DEVICE_TYPE=%s", n.Payload.DeviceType)) copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_DEVICE_SERIAL=%s", n.Payload.DeviceSerial)) copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_MESSAGE=%s", n.Payload.Message)) + if len(n.Payload.HostId) > 0 { + copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_HOST_ID=%s", n.Payload.HostId)) + } err := utils.CmdExec(scriptPath, []string{}, "", copyEnv, "") if err != nil { n.Logger.Errorf("An error occurred while executing script %s: %v", scriptPath, err) diff --git a/webapp/backend/pkg/notify/notify_test.go b/webapp/backend/pkg/notify/notify_test.go index aadb5f91..c76a9249 100644 --- a/webapp/backend/pkg/notify/notify_test.go +++ b/webapp/backend/pkg/notify/notify_test.go @@ -1,11 +1,13 @@ package notify import ( + "fmt" "github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg/models" "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" "github.com/stretchr/testify/require" "testing" + "time" ) func TestShouldNotify_MustSkipPassingDevices(t *testing.T) { @@ -15,56 +17,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 +77,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 +98,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 +116,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 +134,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 +155,90 @@ 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)) +} + +func TestNewPayload(t *testing.T) { + t.Parallel() + + //setup + device := models.Device{ + SerialNumber: "FAKEWDDJ324KSO", + DeviceType: pkg.DeviceProtocolAta, + DeviceName: "/dev/sda", + DeviceStatus: pkg.DeviceStatusFailedScrutiny, + } + currentTime := time.Now() + //test + + payload := NewPayload(device, false, currentTime) + + //assert + require.Equal(t, "Scrutiny SMART error (ScrutinyFailure) detected on device: /dev/sda", payload.Subject) + require.Equal(t, fmt.Sprintf(`Scrutiny SMART error notification for device: /dev/sda +Failure Type: ScrutinyFailure +Device Name: /dev/sda +Device Serial: FAKEWDDJ324KSO +Device Type: ATA + +Date: %s`, currentTime.Format(time.RFC3339)), payload.Message) +} + +func TestNewPayload_TestMode(t *testing.T) { + t.Parallel() + + //setup + device := models.Device{ + SerialNumber: "FAKEWDDJ324KSO", + DeviceType: pkg.DeviceProtocolAta, + DeviceName: "/dev/sda", + DeviceStatus: pkg.DeviceStatusFailedScrutiny, + } + currentTime := time.Now() + //test + + payload := NewPayload(device, true, currentTime) + + //assert + require.Equal(t, "Scrutiny SMART error (EmailTest) detected on device: /dev/sda", payload.Subject) + require.Equal(t, fmt.Sprintf(`TEST NOTIFICATION: +Scrutiny SMART error notification for device: /dev/sda +Failure Type: EmailTest +Device Name: /dev/sda +Device Serial: FAKEWDDJ324KSO +Device Type: ATA + +Date: %s`, currentTime.Format(time.RFC3339)), payload.Message) +} + +func TestNewPayload_WithHostId(t *testing.T) { + t.Parallel() + + //setup + device := models.Device{ + SerialNumber: "FAKEWDDJ324KSO", + DeviceType: pkg.DeviceProtocolAta, + DeviceName: "/dev/sda", + DeviceStatus: pkg.DeviceStatusFailedScrutiny, + HostId: "custom-host", + } + currentTime := time.Now() + //test + + payload := NewPayload(device, false, currentTime) + + //assert + require.Equal(t, "Scrutiny SMART error (ScrutinyFailure) detected on [host]device: [custom-host]/dev/sda", payload.Subject) + require.Equal(t, fmt.Sprintf(`Scrutiny SMART error notification for device: /dev/sda +Host Id: custom-host +Failure Type: ScrutinyFailure +Device Name: /dev/sda +Device Serial: FAKEWDDJ324KSO +Device Type: ATA + +Date: %s`, currentTime.Format(time.RFC3339)), payload.Message) } diff --git a/webapp/backend/pkg/web/handler/delete_device.go b/webapp/backend/pkg/web/handler/delete_device.go index 63336c96..f8a507d3 100644 --- a/webapp/backend/pkg/web/handler/delete_device.go +++ b/webapp/backend/pkg/web/handler/delete_device.go @@ -8,7 +8,7 @@ import ( ) func DeleteDevice(c *gin.Context) { - logger := c.MustGet("LOGGER").(logrus.FieldLogger) + logger := c.MustGet("LOGGER").(*logrus.Entry) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) err := deviceRepo.DeleteDevice(c, c.Param("wwn")) diff --git a/webapp/backend/pkg/web/handler/get_device_details.go b/webapp/backend/pkg/web/handler/get_device_details.go index 864b8429..b4e24eeb 100644 --- a/webapp/backend/pkg/web/handler/get_device_details.go +++ b/webapp/backend/pkg/web/handler/get_device_details.go @@ -9,7 +9,7 @@ import ( ) func GetDeviceDetails(c *gin.Context) { - logger := c.MustGet("LOGGER").(logrus.FieldLogger) + logger := c.MustGet("LOGGER").(*logrus.Entry) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) device, err := deviceRepo.GetDeviceDetails(c, c.Param("wwn")) diff --git a/webapp/backend/pkg/web/handler/get_devices_summary.go b/webapp/backend/pkg/web/handler/get_devices_summary.go index 56e3eb55..a256f4ba 100644 --- a/webapp/backend/pkg/web/handler/get_devices_summary.go +++ b/webapp/backend/pkg/web/handler/get_devices_summary.go @@ -8,7 +8,7 @@ import ( ) func GetDevicesSummary(c *gin.Context) { - logger := c.MustGet("LOGGER").(logrus.FieldLogger) + logger := c.MustGet("LOGGER").(*logrus.Entry) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) summary, err := deviceRepo.GetSummary(c) diff --git a/webapp/backend/pkg/web/handler/get_devices_summary_temp_history.go b/webapp/backend/pkg/web/handler/get_devices_summary_temp_history.go index 631b9ec9..b822dc90 100644 --- a/webapp/backend/pkg/web/handler/get_devices_summary_temp_history.go +++ b/webapp/backend/pkg/web/handler/get_devices_summary_temp_history.go @@ -8,7 +8,7 @@ import ( ) func GetDevicesSummaryTempHistory(c *gin.Context) { - logger := c.MustGet("LOGGER").(logrus.FieldLogger) + logger := c.MustGet("LOGGER").(*logrus.Entry) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) durationKey, exists := c.GetQuery("duration_key") 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 00000000..82a1bc61 --- /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.Entry) + 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/register_devices.go b/webapp/backend/pkg/web/handler/register_devices.go index cb0c59b6..38132ad1 100644 --- a/webapp/backend/pkg/web/handler/register_devices.go +++ b/webapp/backend/pkg/web/handler/register_devices.go @@ -13,7 +13,7 @@ import ( // This function is run everytime a collector is about to start a run. It can be used to update device metadata. func RegisterDevices(c *gin.Context) { deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) - logger := c.MustGet("LOGGER").(logrus.FieldLogger) + logger := c.MustGet("LOGGER").(*logrus.Entry) var collectorDeviceWrapper models.DeviceWrapper err := c.BindJSON(&collectorDeviceWrapper) 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 00000000..6706aaa2 --- /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.Entry) + 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/send_test_notification.go b/webapp/backend/pkg/web/handler/send_test_notification.go index dae55ab9..4d8e56d4 100644 --- a/webapp/backend/pkg/web/handler/send_test_notification.go +++ b/webapp/backend/pkg/web/handler/send_test_notification.go @@ -13,7 +13,7 @@ import ( // Send test notification func SendTestNotification(c *gin.Context) { appConfig := c.MustGet("CONFIG").(config.Interface) - logger := c.MustGet("LOGGER").(logrus.FieldLogger) + logger := c.MustGet("LOGGER").(*logrus.Entry) testNotify := notify.New( logger, diff --git a/webapp/backend/pkg/web/handler/upload_device_metrics.go b/webapp/backend/pkg/web/handler/upload_device_metrics.go index d27f66bf..f58d6ed6 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" @@ -13,7 +14,7 @@ import ( func UploadDeviceMetrics(c *gin.Context) { //db := c.MustGet("DB").(*gorm.DB) - logger := c.MustGet("LOGGER").(logrus.FieldLogger) + logger := c.MustGet("LOGGER").(*logrus.Entry) appConfig := c.MustGet("CONFIG").(config.Interface) //influxWriteDb := c.MustGet("INFLUXDB_WRITE").(*api.WriteAPIBlocking) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) @@ -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 dc988bb3..8540d5fa 100644 --- a/webapp/backend/pkg/web/middleware/logger.go +++ b/webapp/backend/pkg/web/middleware/logger.go @@ -28,11 +28,11 @@ import ( var timeFormat = "02/Jan/2006:15:04:05 -0700" // Logger is the logrus logger handler -func LoggerMiddleware(logger logrus.FieldLogger) gin.HandlerFunc { +func LoggerMiddleware(logger *logrus.Entry) 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 3fe58d2c..f545a335 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 d2a5cf6f..bb82405d 100644 --- a/webapp/backend/pkg/web/server.go +++ b/webapp/backend/pkg/web/server.go @@ -9,18 +9,17 @@ import ( "github.com/analogj/scrutiny/webapp/backend/pkg/web/middleware" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "io" "net/http" - "os" "path/filepath" "strings" ) type AppEngine struct { Config config.Interface + Logger *logrus.Entry } -func (ae *AppEngine) Setup(logger logrus.FieldLogger) *gin.Engine { +func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine { r := gin.New() r.Use(middleware.LoggerMiddleware(logger)) @@ -36,6 +35,10 @@ func (ae *AppEngine) Setup(logger logrus.FieldLogger) *gin.Engine { api := base.Group("/api") { api.GET("/health", func(c *gin.Context) { + //TODO: + // check if the /web folder is populated. + // check if access to influxdb + // check if access to sqlitedb. c.JSON(http.StatusOK, gin.H{ "success": true, }) @@ -50,6 +53,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 } } @@ -75,26 +80,6 @@ func (ae *AppEngine) Start() error { gin.SetMode(gin.DebugMode) } - logger := logrus.New() - //set default log level - logLevel, err := logrus.ParseLevel(ae.Config.GetString("log.level")) - if err != nil { - return err - } - logger.SetLevel(logLevel) - //set the log file if present - if len(ae.Config.GetString("log.file")) != 0 { - logFile, err := os.OpenFile(ae.Config.GetString("log.file"), os.O_CREATE|os.O_WRONLY, 0644) - defer logFile.Close() - if err != nil { - logrus.Errorf("Failed to open log file %s for output: %s", ae.Config.GetString("log.file"), err) - return err - } - - //configure the logrus default - logger.SetOutput(io.MultiWriter(os.Stderr, logFile)) - } - //check if the database parent directory exists, fail here rather than in a handler. if !utils.FileExists(filepath.Dir(ae.Config.GetString("web.database.location"))) { return errors.ConfigValidationError(fmt.Sprintf( @@ -102,7 +87,7 @@ func (ae *AppEngine) Start() error { filepath.Dir(ae.Config.GetString("web.database.location")))) } - r := ae.Setup(logger) + r := ae.Setup(ae.Logger) return r.Run(fmt.Sprintf("%s:%s", ae.Config.GetString("web.listen.host"), ae.Config.GetString("web.listen.port"))) } diff --git a/webapp/backend/pkg/web/server_test.go b/webapp/backend/pkg/web/server_test.go index b64617c1..227d3a2f 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() @@ -111,7 +115,7 @@ func (suite *ServerTestSuite) TestHealthRoute() { Config: fakeConfig, } - router := ae.Setup(logrus.New()) + router := ae.Setup(logrus.WithField("test", suite.T().Name())) //test w := httptest.NewRecorder() @@ -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() @@ -150,7 +156,7 @@ func (suite *ServerTestSuite) TestRegisterDevicesRoute() { ae := web.AppEngine{ Config: fakeConfig, } - router := ae.Setup(logrus.New()) + router := ae.Setup(logrus.WithField("test", suite.T().Name())) file, err := os.Open("testdata/register-devices-req.json") require.NoError(suite.T(), err) @@ -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,13 +194,14 @@ 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, } - router := ae.Setup(logrus.New()) + router := ae.Setup(logrus.WithField("test", suite.T().Name())) devicesfile, err := os.Open("testdata/register-devices-single-req.json") require.NoError(suite.T(), err) @@ -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() @@ -243,7 +255,7 @@ func (suite *ServerTestSuite) TestPopulateMultiple() { ae := web.AppEngine{ Config: fakeConfig, } - router := ae.Setup(logrus.New()) + router := ae.Setup(logrus.WithField("test", suite.T().Name())) devicesfile, err := os.Open("testdata/register-devices-req.json") require.NoError(suite.T(), err) @@ -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. @@ -343,7 +358,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_WebhookFailure() { ae := web.AppEngine{ Config: fakeConfig, } - router := ae.Setup(logrus.New()) + router := ae.Setup(logrus.WithField("test", suite.T().Name())) //test wr := httptest.NewRecorder() @@ -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. @@ -385,7 +403,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptFailure() { ae := web.AppEngine{ Config: fakeConfig, } - router := ae.Setup(logrus.New()) + router := ae.Setup(logrus.WithField("test", suite.T().Name())) //test wr := httptest.NewRecorder() @@ -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. @@ -427,7 +448,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptSuccess() { ae := web.AppEngine{ Config: fakeConfig, } - router := ae.Setup(logrus.New()) + router := ae.Setup(logrus.WithField("test", suite.T().Name())) //test wr := httptest.NewRecorder() @@ -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. @@ -468,7 +492,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ShoutrrrFailure() { ae := web.AppEngine{ Config: fakeConfig, } - router := ae.Setup(logrus.New()) + router := ae.Setup(logrus.WithField("test", suite.T().Name())) //test wr := httptest.NewRecorder() @@ -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() @@ -509,7 +537,7 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() { ae := web.AppEngine{ Config: fakeConfig, } - router := ae.Setup(logrus.New()) + router := ae.Setup(logrus.WithField("test", suite.T().Name())) devicesfile, err := os.Open("testdata/register-devices-req-2.json") require.NoError(suite.T(), err) diff --git a/webapp/frontend/src/app/app.module.ts b/webapp/frontend/src/app/app.module.ts index 904ee153..8d0d0609 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 74143c5b..92f04512 100644 --- a/webapp/frontend/src/app/core/config/app.config.ts +++ b/webapp/frontend/src/app/core/config/app.config.ts @@ -10,19 +10,49 @@ 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; + + file_size_si_units?: boolean; + + // Settings from Scrutiny API + + metrics?: { + notify_level?: MetricsNotifyLevel + status_filter_attributes?: MetricsStatusFilterAttributes + status_threshold?: MetricsStatusThreshold + } - temperatureUnit: TemperatureUnit; } /** @@ -34,12 +64,19 @@ export interface AppConfig { * "ConfigService". */ export const appConfig: AppConfig = { - theme : 'light', + theme: 'light', layout: 'material', - dashboardDisplay: 'name', - dashboardSort: 'status', + dashboard_display: 'name', + dashboard_sort: 'status', + + temperature_unit: 'celsius', + file_size_si_units: false, - temperatureUnit: '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 00000000..3dd5bc85 --- /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 00000000..4c6d7b9c --- /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 e7b0ffd7..43e89642 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,17 +46,20 @@
Status
-
{{ deviceStatusString(deviceSummary.device.device_status) | titlecase}}
+
{{ deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) | titlecase}}
No Data
Temperature
-
{{ deviceSummary.smart?.temp | temperature:config.temperatureUnit:true }}
+
{{ deviceSummary.smart?.temp | temperature:config.temperature_unit:true }}
--
Capacity
-
{{ deviceSummary.device.capacity | fileSize}}
+
{{ deviceSummary.device.capacity | fileSize:config.file_size_si_units}}
Powered On
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 7b334bb8..6e578a13 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,26 +9,38 @@ 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'; +import {HttpClient} from '@angular/common/http'; +import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service'; +import {of} from 'rxjs'; +import {MetricsStatusThreshold} from 'app/core/config/app.config'; 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$']); + let configService: ScrutinyConfigService; + let httpClientSpy: jasmine.SpyObj; beforeEach(async(() => { + + httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']); + configService = new ScrutinyConfigService(httpClientSpy, {}); + TestBed.configureTestingModule({ imports: [ MatButtonModule, MatIconModule, MatMenuModule, SharedModule, + HttpClientTestingModule, ], providers: [ {provide: MatDialog, useValue: matDialogSpy}, - {provide: TREO_APP_CONFIG, useValue: {dashboardDisplay: 'name'}} + {provide: TREO_APP_CONFIG, useValue: {dashboard_display: 'name', metrics: {status_threshold: 3}}}, + {provide: ScrutinyConfigService, useValue: configService} ], declarations: [DashboardDeviceComponent] }) @@ -48,25 +60,53 @@ describe('DashboardDeviceComponent', () => { describe('#classDeviceLastUpdatedOn()', () => { it('if non-zero device status, should be red', () => { + httpClientSpy.get.and.returnValue(of({ + settings: { + metrics: { + status_threshold: MetricsStatusThreshold.Both, + } + } + })); + component.ngOnInit() // component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel expect(component.classDeviceLastUpdatedOn({ device: { - device_status: 2 - } + device_status: 2, + }, + smart: { + collector_date: moment().subtract(13, 'days').toISOString() + }, } as DeviceSummaryModel)).toBe('text-red') }); it('if non-zero device status, should be red', () => { - // component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel + httpClientSpy.get.and.returnValue(of({ + settings: { + metrics: { + status_threshold: MetricsStatusThreshold.Both, + } + } + })); + component.ngOnInit() expect(component.classDeviceLastUpdatedOn({ device: { device_status: 2 - } + }, + smart: { + collector_date: moment().subtract(13, 'days').toISOString() + }, } as DeviceSummaryModel)).toBe('text-red') }); it('if healthy device status and updated in the last two weeks, should be green', () => { - // component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel + httpClientSpy.get.and.returnValue(of({ + settings: { + metrics: { + status_threshold: MetricsStatusThreshold.Both, + } + } + })); + component.ngOnInit() expect(component.classDeviceLastUpdatedOn({ device: { device_status: 0 @@ -78,7 +118,14 @@ describe('DashboardDeviceComponent', () => { }); it('if healthy device status and updated more than two weeks ago, but less than 1 month, should be yellow', () => { - // component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel + httpClientSpy.get.and.returnValue(of({ + settings: { + metrics: { + status_threshold: MetricsStatusThreshold.Both, + } + } + })); + component.ngOnInit() expect(component.classDeviceLastUpdatedOn({ device: { device_status: 0 @@ -90,7 +137,14 @@ describe('DashboardDeviceComponent', () => { }); it('if healthy device status and updated more 1 month ago, should be red', () => { - // component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel + httpClientSpy.get.and.returnValue(of({ + settings: { + metrics: { + status_threshold: MetricsStatusThreshold.Both, + } + } + })); + component.ngOnInit() expect(component.classDeviceLastUpdatedOn({ device: { device_status: 0 @@ -101,5 +155,4 @@ describe('DashboardDeviceComponent', () => { } as DeviceSummaryModel)).toBe('text-red') }); }) - }); 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 4fb7d7ac..e29957e1 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,13 +2,14 @@ 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'; import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component'; import {DeviceTitlePipe} from 'app/shared/device-title.pipe'; import {DeviceSummaryModel} from 'app/core/models/device-summary-model'; +import {DeviceStatusPipe} from 'app/shared/device-status.pipe'; @Component({ selector: 'app-dashboard-device', @@ -18,7 +19,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 @@ -35,6 +36,8 @@ export class DashboardDeviceComponent implements OnInit { readonly humanizeDuration = humanizeDuration; + deviceStatusForModelWithThreshold = DeviceStatusPipe.deviceStatusForModelWithThreshold + ngOnInit(): void { // Subscribe to config changes this._configService.config$ @@ -50,9 +53,10 @@ export class DashboardDeviceComponent implements OnInit { // ----------------------------------------------------------------------------------------------------- classDeviceLastUpdatedOn(deviceSummary: DeviceSummaryModel): string { - if (deviceSummary.device.device_status !== 0) { + const deviceStatus = DeviceStatusPipe.deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, this.config.metrics.status_threshold) + if (deviceStatus === 'failed') { return 'text-red' // if the device has failed, always highlight in red - } else if (deviceSummary.device.device_status === 0 && deviceSummary.smart) { + } else if (deviceStatus === 'passed') { if (moment().subtract(14, 'days').isBefore(deviceSummary.smart.collector_date)) { // this device was updated in the last 2 weeks. return 'text-green' @@ -68,21 +72,12 @@ export class DashboardDeviceComponent implements OnInit { } } - deviceStatusString(deviceStatus: number): string { - if (deviceStatus === 0) { - return 'passed' - } else { - return 'failed' - } - } - - openDeleteDialog(): void { const dialogRef = this.dialog.open(DashboardDeviceDeleteDialogComponent, { // 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 a10d5500..750d54db 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 @@ -37,69 +37,41 @@

Scrutiny Settings

- Temperature Display Unit + Temperature Celsius Fahrenheit + + File Size + + SI Units (GB) + Binary Units (GiB) + +
-
- - - -
- - 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 70a0978a..6bc5f2a9 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'; @@ -14,13 +22,16 @@ export class DashboardSettingsComponent implements OnInit { dashboardDisplay: string; dashboardSort: string; temperatureUnit: string; + fileSizeSIUnits: boolean; 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 +44,30 @@ 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.fileSizeSIUnits = config.file_size_si_units; 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, + file_size_si_units: this.fileSizeSIUnits, + 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 6a3a68bb..8e567a64 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 d370ab57..f1061314 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 @@

Dashboard

{{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 7352e98a..70b40f6c 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; } } }, @@ -237,7 +237,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy } openDialog(): void { - const dialogRef = this.dialog.open(DashboardSettingsComponent); + const dialogRef = this.dialog.open(DashboardSettingsComponent, {width: '600px',}); dialogRef.afterClosed().subscribe(result => { console.log(`Dialog result: ${result}`); diff --git a/webapp/frontend/src/app/modules/detail/detail.component.html b/webapp/frontend/src/app/modules/detail/detail.component.html index da8ad7ac..0f377782 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
@@ -17,7 +17,7 @@

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

diff --git a/webapp/frontend/src/app/modules/detail/detail.component.ts b/webapp/frontend/src/app/modules/detail/detail.component.ts index 0e29652b..e87b75f6 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'; @@ -16,6 +16,7 @@ import {DeviceModel} from 'app/core/models/device-model'; import {SmartModel} from 'app/core/models/measurements/smart-model'; import {SmartAttributeModel} from 'app/core/models/measurements/smart-attribute-model'; import {AttributeMetadataModel} from 'app/core/models/thresholds/attribute-metadata-model'; +import {DeviceStatusPipe} from 'app/shared/device-status.pipe'; // from Constants.go - these must match const AttributeStatusPassed = 0 @@ -44,13 +45,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 @@ -89,6 +90,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { readonly humanizeDuration = humanizeDuration; + deviceStatusForModelWithThreshold = DeviceStatusPipe.deviceStatusForModelWithThreshold // ----------------------------------------------------------------------------------------------------- // @ Lifecycle hooks // ----------------------------------------------------------------------------------------------------- @@ -349,7 +351,9 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { attributes[attrId].chartData = [ { name: 'chart-line-sparkline', - data: attrHistory + // attrHistory needs to be reversed, so the newest data is on the right + // fixes #339 + data: attrHistory.reverse() } ] } diff --git a/webapp/frontend/src/app/shared/device-status.pipe.spec.ts b/webapp/frontend/src/app/shared/device-status.pipe.spec.ts index 23bc9583..57d9e7c6 100644 --- a/webapp/frontend/src/app/shared/device-status.pipe.spec.ts +++ b/webapp/frontend/src/app/shared/device-status.pipe.spec.ts @@ -1,8 +1,146 @@ -import { DeviceStatusPipe } from './device-status.pipe'; +import {DeviceStatusPipe} from './device-status.pipe'; +import {MetricsStatusThreshold} from '../core/config/app.config'; +import {DeviceModel} from '../core/models/device-model'; describe('DeviceStatusPipe', () => { - it('create an instance', () => { - const pipe = new DeviceStatusPipe(); - expect(pipe).toBeTruthy(); - }); + it('create an instance', () => { + const pipe = new DeviceStatusPipe(); + expect(pipe).toBeTruthy(); + }); + + describe('#deviceStatusForModelWithThreshold', () => { + it('if healthy device, should be passing', () => { + expect(DeviceStatusPipe.deviceStatusForModelWithThreshold( + {device_status: 0} as DeviceModel, + true, + MetricsStatusThreshold.Both + )).toBe('passed') + }); + + it('if device with no smart data, should be unknown', () => { + expect(DeviceStatusPipe.deviceStatusForModelWithThreshold( + {device_status: 0} as DeviceModel, + false, + MetricsStatusThreshold.Both + )).toBe('unknown') + }); + + const testCases = [ + { + 'deviceStatus': 10000, // invalid status + 'hasSmartResults': false, + 'threshold': MetricsStatusThreshold.Smart, + 'includeReason': false, + 'result': 'unknown' + }, + + { + 'deviceStatus': 1, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Smart, + 'includeReason': false, + 'result': 'failed' + }, + { + 'deviceStatus': 1, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Scrutiny, + 'includeReason': false, + 'result': 'passed' + }, + { + 'deviceStatus': 1, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Both, + 'includeReason': false, + 'result': 'failed' + }, + + { + 'deviceStatus': 2, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Smart, + 'includeReason': false, + 'result': 'passed' + }, + { + 'deviceStatus': 2, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Scrutiny, + 'includeReason': false, + 'result': 'failed' + }, + { + 'deviceStatus': 2, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Both, + 'includeReason': false, + 'result': 'failed' + }, + + { + 'deviceStatus': 3, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Smart, + 'includeReason': false, + 'result': 'failed' + }, + { + 'deviceStatus': 3, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Scrutiny, + 'includeReason': false, + 'result': 'failed' + }, + { + 'deviceStatus': 3, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Both, + 'includeReason': false, + 'result': 'failed' + }, + + { + 'deviceStatus': 3, + 'hasSmartResults': false, + 'threshold': MetricsStatusThreshold.Smart, + 'includeReason': true, + 'result': 'unknown' + }, + { + 'deviceStatus': 3, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Smart, + 'includeReason': true, + 'result': 'failed: smart' + }, + { + 'deviceStatus': 3, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Scrutiny, + 'includeReason': true, + 'result': 'failed: scrutiny' + }, + { + 'deviceStatus': 3, + 'hasSmartResults': true, + 'threshold': MetricsStatusThreshold.Both, + 'includeReason': true, + 'result': 'failed: both' + } + + + ] + + testCases.forEach((test, index) => { + it(`if device with status (${test.deviceStatus}), hasSmartResults(${test.hasSmartResults}) and threshold (${test.threshold}), should be ${test.result}`, () => { + expect(DeviceStatusPipe.deviceStatusForModelWithThreshold( + {device_status: test.deviceStatus} as DeviceModel, + test.hasSmartResults, + test.threshold, + test.includeReason + )).toBe(test.result) + }); + }); + }); }); diff --git a/webapp/frontend/src/app/shared/device-status.pipe.ts b/webapp/frontend/src/app/shared/device-status.pipe.ts index 42261c61..68a692d3 100644 --- a/webapp/frontend/src/app/shared/device-status.pipe.ts +++ b/webapp/frontend/src/app/shared/device-status.pipe.ts @@ -1,21 +1,71 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; +import {MetricsStatusThreshold} from '../core/config/app.config'; +import {DeviceModel} from '../core/models/device-model'; + +const DEVICE_STATUS_NAMES: { [key: number]: string } = { + 0: 'passed', + 1: 'failed', + 2: 'failed', + 3: 'failed' +}; + +const DEVICE_STATUS_NAMES_WITH_REASON: { [key: number]: string } = { + 0: 'passed', + 1: 'failed: smart', + 2: 'failed: scrutiny', + 3: 'failed: both' +}; + @Pipe({ - name: 'deviceStatus' + name: 'deviceStatus' }) export class DeviceStatusPipe implements PipeTransform { - transform(deviceStatusFlag: number): string { - if(deviceStatusFlag === 0){ - return 'passed' - } else if(deviceStatusFlag === 3){ - return 'failed: both' - } else if(deviceStatusFlag === 2) { - return 'failed: scrutiny' - } else if(deviceStatusFlag === 1) { - return 'failed: smart' - } - return 'unknown' - } + + static deviceStatusForModelWithThreshold( + deviceModel: DeviceModel, + hasSmartResults: boolean = true, + threshold: MetricsStatusThreshold = MetricsStatusThreshold.Both, + includeReason: boolean = false + ): string { + // no smart data, so treat the device status as unknown + if (!hasSmartResults) { + return 'unknown' + } + + let statusNameLookup = DEVICE_STATUS_NAMES + if (includeReason) { + statusNameLookup = DEVICE_STATUS_NAMES_WITH_REASON + } + // determine the device status, by comparing it against the allowed threshold + // tslint:disable-next-line:no-bitwise + const deviceStatus = deviceModel.device_status & threshold + return statusNameLookup[deviceStatus] + } + + // static deviceStatusForModelWithThreshold(deviceModel: DeviceModel | any, threshold: MetricsStatusThreshold): string { + // // tslint:disable-next-line:no-bitwise + // const deviceStatus = deviceModel?.device_status & threshold + // if(deviceStatus === 0){ + // return 'passed' + // } else if(deviceStatus === 3){ + // return 'failed: both' + // } else if(deviceStatus === 2) { + // return 'failed: scrutiny' + // } else if(deviceStatus === 1) { + // return 'failed: smart' + // } + // return 'unknown' + // } + + transform( + deviceModel: DeviceModel, + hasSmartResults: boolean = true, + threshold: MetricsStatusThreshold = MetricsStatusThreshold.Both, + includeReason: boolean = false + ): string { + return DeviceStatusPipe.deviceStatusForModelWithThreshold(deviceModel, hasSmartResults, threshold, includeReason) + } } diff --git a/webapp/frontend/src/app/shared/file-size.pipe.spec.ts b/webapp/frontend/src/app/shared/file-size.pipe.spec.ts index 14973cf9..0f2127a8 100644 --- a/webapp/frontend/src/app/shared/file-size.pipe.spec.ts +++ b/webapp/frontend/src/app/shared/file-size.pipe.spec.ts @@ -1,4 +1,4 @@ -import { FileSizePipe } from './file-size.pipe'; +import {FileSizePipe} from './file-size.pipe'; describe('FileSizePipe', () => { it('create an instance', () => { @@ -10,23 +10,61 @@ describe('FileSizePipe', () => { const testCases = [ { 'bytes': 1500, - 'precision': undefined, - 'result': '1 KB' - },{ - 'bytes': 2_100_000_000, - 'precision': undefined, - 'result': '2.0 GB', - },{ + 'si': false, + 'result': '1.5 KiB' + }, + { 'bytes': 1500, - 'precision': 2, - 'result': '1.46 KB', + 'si': true, + 'result': '1.5 kB' + }, + { + 'bytes': 5000, + 'si': false, + 'result': '4.9 KiB', + }, + { + 'bytes': 5000, + 'si': true, + 'result': '5.0 kB', + }, + { + 'bytes': 999_949, + 'si': false, + 'result': '976.5 KiB', + }, + { + 'bytes': 999_949, + 'si': true, + 'result': '999.9 kB', + }, + { + 'bytes': 999_950, + 'si': true, + 'result': '1.0 MB', + }, + { + 'bytes': 1_551_859_712, + 'si': false, + 'result': '1.4 GiB', + }, + { + 'bytes': 2_100_000_000, + 'si': false, + 'result': '2.0 GiB', + }, + { + 'bytes': 2_100_000_000, + 'si': true, + 'result': '2.1 GB', } ] + testCases.forEach((test, index) => { it(`should correctly format bytes ${test.bytes}. (testcase: ${index + 1})`, () => { // test const pipe = new FileSizePipe(); - const formatted = pipe.transform(test.bytes, test.precision) + const formatted = pipe.transform(test.bytes, test.si) expect(formatted).toEqual(test.result); }); }) diff --git a/webapp/frontend/src/app/shared/file-size.pipe.ts b/webapp/frontend/src/app/shared/file-size.pipe.ts index e6cbc7b1..14fdf0c9 100644 --- a/webapp/frontend/src/app/shared/file-size.pipe.ts +++ b/webapp/frontend/src/app/shared/file-size.pipe.ts @@ -1,75 +1,27 @@ -/** - * @license - * Copyright (c) 2019 Jonathan Catmull. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; -type unit = 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB'; -type unitPrecisionMap = { - [u in unit]: number; -}; - -const defaultPrecisionMap: unitPrecisionMap = { - bytes: 0, - KB: 0, - MB: 1, - GB: 1, - TB: 2, - PB: 2 -}; - -/* - * Convert bytes into largest possible unit. - * Takes an precision argument that can be a number or a map for each unit. - * Usage: - * bytes | fileSize:precision - * @example - * // returns 1 KB - * {{ 1500 | fileSize }} - * @example - * // returns 2.1 GB - * {{ 2100000000 | fileSize }} - * @example - * // returns 1.46 KB - * {{ 1500 | fileSize:2 }} - */ -@Pipe({ name: 'fileSize' }) +@Pipe({name: 'fileSize'}) export class FileSizePipe implements PipeTransform { - private readonly units: unit[] = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; - - transform(bytes: number = 0, precision: number | unitPrecisionMap = defaultPrecisionMap): string { - if (isNaN(parseFloat(String(bytes))) || !isFinite(bytes)) return '?'; - let unitIndex = 0; + transform(bytes: number = 0, si = false, dp = 1): string { + const thresh = si ? 1000 : 1024; - while (bytes >= 1024) { - bytes /= 1024; - unitIndex++; + if (Math.abs(bytes) < thresh) { + return bytes + ' B'; } - const unit = this.units[unitIndex]; + const units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + let u = -1; + const r = 10 ** dp; - if (typeof precision === 'number') { - return `${bytes.toFixed(+precision)} ${unit}`; - } - return `${bytes.toFixed(precision[unit])} ${unit}`; + do { + bytes /= thresh; + ++u; + } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); + + + return bytes.toFixed(dp) + ' ' + units[u]; } }