Merge pull request #352 from AnalogJ/beta

This commit is contained in:
Jason Kulatunga
2022-08-04 08:07:56 -07:00
committed by GitHub
57 changed files with 1585 additions and 504 deletions
+3 -3
View File
@@ -239,9 +239,9 @@ scrutiny-collector-metrics run --debug --log-file /tmp/collector.log
| linux-arm-6 | :white_check_mark: | | | 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-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: | | linux-arm64 | :white_check_mark: | :white_check_mark: |
| freebsd-amd64 | collector only. see [#238](https://github.com/AnalogJ/scrutiny/issues/238) | | | freebsd-amd64 | :white_check_mark: | |
| macos-amd64 | | :white_check_mark: | | macos-amd64 | :white_check_mark: | :white_check_mark: |
| macos-arm64 | | :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-amd64 | :white_check_mark: | WIP, see [#15](https://github.com/AnalogJ/scrutiny/issues/15) |
| windows-arm64 | :white_check_mark: | | | windows-arm64 | :white_check_mark: | |
@@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"github.com/analogj/scrutiny/collector/pkg/collector" "github.com/analogj/scrutiny/collector/pkg/collector"
"github.com/analogj/scrutiny/collector/pkg/config" "github.com/analogj/scrutiny/collector/pkg/config"
@@ -120,26 +121,16 @@ OPTIONS:
config.Set("api.endpoint", apiEndpoint) config.Set("api.endpoint", apiEndpoint)
} }
collectorLogger := logrus.WithFields(logrus.Fields{ collectorLogger, logFile, err := CreateLogger(config)
"type": "metrics", if logFile != nil {
})
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
}
defer logFile.Close() 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( metricCollector, err := collector.CreateMetricsCollector(
config, config,
collectorLogger, collectorLogger,
@@ -192,5 +183,28 @@ OPTIONS:
if err != nil { if err != nil {
log.Fatal(color.HiRedString("ERROR: %v", err)) 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
} }
+1 -1
View File
@@ -14,7 +14,7 @@ RUN make binary-clean binary-collector
######## ########
FROM debian:bullseye-slim as runtime FROM debian:bullseye-slim as runtime
WORKDIR /scrutiny WORKDIR /opt/scrutiny
ENV PATH="/opt/scrutiny/bin:${PATH}" ENV PATH="/opt/scrutiny/bin:${PATH}"
RUN apt-get update && apt-get install -y cron smartmontools ca-certificates tzdata && update-ca-certificates RUN apt-get update && apt-get install -y cron smartmontools ca-certificates tzdata && update-ca-certificates
+6 -2
View File
@@ -91,9 +91,13 @@ wget https://raw.githubusercontent.com/smartmontools/smartmontools/master/smartm
``` ```
#!/bin/bash #!/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. ## Set up Synology to run a scheduled task.
Log in to DSM and do the following: Log in to DSM and do the following:
@@ -131,4 +135,4 @@ Frequency: <Your desired frequency>
## Troubleshooting ## 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) 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)
+14 -2
View File
@@ -1,7 +1,19 @@
# InfluxDB Troubleshooting # InfluxDB Troubleshooting
## Installation ## Why??
InfluxDB is a required dependency for Scrutiny v0.4.0+.
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/ https://docs.influxdata.com/influxdb/v2.2/install/
+1
View File
@@ -21,5 +21,6 @@ SCRUTINY_DEVICE_NAME - eg. /dev/sda
SCRUTINY_DEVICE_TYPE - ATA/SCSI/NVMe SCRUTINY_DEVICE_TYPE - ATA/SCSI/NVMe
SCRUTINY_DEVICE_SERIAL - eg. WDDJ324KSO 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_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"
``` ```
+18
View File
@@ -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`.
+70 -44
View File
@@ -1,62 +1,88 @@
// SQLite Table(s) // SQLite Table(s)
Table device {
created_at timestamp
wwn varchar [pk] Table Device {
//GORM attributes, see: http://gorm.io/docs/conventions.html
CreatedAt time
UpdatedAt time
DeletedAt time
//user provided WWN string
label varchar
host_id varchar
// smartctl provided DeviceName string
device_name varchar DeviceUUID string
manufacturer varchar DeviceSerialID string
model_name varchar DeviceLabel string
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
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
SettingKeyName string
SettingKeyDescription string
SettingDataType string
SettingValueNumeric int64
SettingValueString string
} }
// InfluxDB Tables // InfluxDB Tables
Table device_temperature { Table SmartTemperature {
//timestamp Date time
created_at timestamp DeviceWWN string //(tag)
Temp int64
//tags (indexed & queryable) }
device_wwn varchar [pk]
//fields
temp bigint
}
Table smart_ata_results { Table Smart {
//timestamp Date time
created_at timestamp DeviceWWN string //(tag)
DeviceProtocol string
//tags (indexed & queryable) //Metrics (fields)
device_wwn varchar [pk] Temp int64
smart_status varchar PowerOnHours int64
scrutiny_status varchar PowerCycleCount int64
//Smart Status
Status enum
//SMART Attributes (fields)
//fields Attr_ID_AttributeId int
temp bigint Attr_ID_Value int64
power_on_hours bigint Attr_ID_Threshold int64
power_cycle_count bigint 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
-2
View File
@@ -73,8 +73,6 @@ log:
# - "join://shoutrrr:api-key@join/?devices=device1[,device2, ...][&icon=icon][&title=title]" # - "join://shoutrrr:api-key@join/?devices=device1[,device2, ...][&icon=icon][&title=title]"
# - "script:///file/path/on/disk" # - "script:///file/path/on/disk"
# - "https://www.example.com/path" # - "https://www.example.com/path"
# filter_attributes: 'all' # options: 'all' or 'critical'
# level: 'fail' # options: 'fail', 'fail_scrutiny', 'fail_smart'
######################################################################################################################## ########################################################################################################################
# FEATURES COMING SOON # FEATURES COMING SOON
+40 -2
View File
@@ -1,12 +1,15 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/config"
"github.com/analogj/scrutiny/webapp/backend/pkg/errors" "github.com/analogj/scrutiny/webapp/backend/pkg/errors"
"github.com/analogj/scrutiny/webapp/backend/pkg/version" "github.com/analogj/scrutiny/webapp/backend/pkg/version"
"github.com/analogj/scrutiny/webapp/backend/pkg/web" "github.com/analogj/scrutiny/webapp/backend/pkg/web"
log "github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"io"
"log"
"os" "os"
"time" "time"
@@ -107,7 +110,18 @@ OPTIONS:
config.Set("log.file", c.String("log-file")) 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() 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
}
+26 -33
View File
@@ -2,7 +2,6 @@ package config
import ( import (
"github.com/analogj/go-util/utils" "github.com/analogj/go-util/utils"
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/errors" "github.com/analogj/scrutiny/webapp/backend/pkg/errors"
"github.com/spf13/viper" "github.com/spf13/viper"
"log" "log"
@@ -10,6 +9,8 @@ import (
"strings" "strings"
) )
const DB_USER_SETTINGS_SUBKEY = "user"
// When initializing this class the following methods must be called: // When initializing this class the following methods must be called:
// Config.New // Config.New
// Config.Init // Config.Init
@@ -39,8 +40,6 @@ func (c *configuration) Init() error {
c.SetDefault("log.file", "") c.SetDefault("log.file", "")
c.SetDefault("notify.urls", []string{}) 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.scheme", "http")
c.SetDefault("web.influxdb.host", "localhost") c.SetDefault("web.influxdb.host", "localhost")
@@ -55,17 +54,6 @@ func (c *configuration) Init() error {
//c.SetDefault("disks.include", []string{}) //c.SetDefault("disks.include", []string{})
//c.SetDefault("disks.exclude", []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 //if you want to load a non-standard location system config file (~/drawbridge.yml), use ReadConfig
c.SetConfigType("yaml") c.SetConfigType("yaml")
//c.SetConfigName("drawbridge") //c.SetConfigName("drawbridge")
@@ -77,7 +65,18 @@ func (c *configuration) Init() error {
c.AutomaticEnv() c.AutomaticEnv()
//CLI options will be added via the `Set()` function //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 { 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. // This function ensures that the merged config works correctly.
func (c *configuration) ValidateConfig() error { func (c *configuration) ValidateConfig() error {
////deserialize Questions //the following keys are deprecated, and no longer supported
//questionsMap := map[string]Question{} /*
//err := c.UnmarshalKey("questions", &questionsMap) - notify.filter_attributes (replaced by metrics.status.filter_attributes SETTING)
// - notify.level (replaced by metrics.notify.level and metrics.status.threshold SETTING)
//if err != nil { */
// log.Printf("questions could not be deserialized correctly. %v", err) //TODO add docs and upgrade doc.
// return err if c.IsSet("notify.filter_attributes") {
//} return errors.ConfigValidationError("`notify.filter_attributes` configuration option is deprecated. Replaced by option in Dashboard Settings page")
// }
//for _, v := range questionsMap { if c.IsSet("notify.level") {
// return errors.ConfigValidationError("`notify.level` configuration option is deprecated. Replaced by option in Dashboard Settings page")
// typeContent, ok := v.Schema["type"].(string) }
// if !ok || len(typeContent) == 0 {
// return errors.QuestionSyntaxError("`type` is required for questions")
// }
//}
//
//
return nil return nil
} }
+34
View File
@@ -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"))
}
+5
View File
@@ -12,12 +12,17 @@ type Interface interface {
WriteConfig() error WriteConfig() error
Set(key string, value interface{}) Set(key string, value interface{})
SetDefault(key string, value interface{}) SetDefault(key string, value interface{})
MergeConfigMap(cfg map[string]interface{}) error
Sub(key string) Interface
AllSettings() map[string]interface{} AllSettings() map[string]interface{}
AllKeys() []string
SubKeys(key string) []string
IsSet(key string) bool IsSet(key string) bool
Get(key string) interface{} Get(key string) interface{}
GetBool(key string) bool GetBool(key string) bool
GetInt(key string) int GetInt(key string) int
GetInt64(key string) int64
GetString(key string) string GetString(key string) string
GetStringSlice(key string) []string GetStringSlice(key string) []string
UnmarshalKey(key string, rawVal interface{}, decoderOpts ...viper.DecoderConfigOption) error UnmarshalKey(key string, rawVal interface{}, decoderOpts ...viper.DecoderConfigOption) error
@@ -7,6 +7,7 @@ package mock_config
import ( import (
reflect "reflect" reflect "reflect"
config "github.com/analogj/scrutiny/webapp/backend/pkg/config"
gomock "github.com/golang/mock/gomock" gomock "github.com/golang/mock/gomock"
viper "github.com/spf13/viper" viper "github.com/spf13/viper"
) )
@@ -34,6 +35,20 @@ func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
return m.recorder 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. // AllSettings mocks base method.
func (m *MockInterface) AllSettings() map[string]interface{} { func (m *MockInterface) AllSettings() map[string]interface{} {
m.ctrl.T.Helper() 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) 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. // GetString mocks base method.
func (m *MockInterface) GetString(key string) string { func (m *MockInterface) GetString(key string) string {
m.ctrl.T.Helper() 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) 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. // ReadConfig mocks base method.
func (m *MockInterface) ReadConfig(configFilePath string) error { func (m *MockInterface) ReadConfig(configFilePath string) error {
m.ctrl.T.Helper() 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) 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. // UnmarshalKey mocks base method.
func (m *MockInterface) UnmarshalKey(key string, rawVal interface{}, decoderOpts ...viper.DecoderConfigOption) error { func (m *MockInterface) UnmarshalKey(key string, rawVal interface{}, decoderOpts ...viper.DecoderConfigOption) error {
m.ctrl.T.Helper() m.ctrl.T.Helper()
+30 -9
View File
@@ -4,17 +4,11 @@ const DeviceProtocolAta = "ATA"
const DeviceProtocolScsi = "SCSI" const DeviceProtocolScsi = "SCSI"
const DeviceProtocolNvme = "NVMe" 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 //go:generate stringer -type=AttributeStatus
// AttributeStatus bitwise flag, 1,2,4,8,16,32,etc
type AttributeStatus uint8 type AttributeStatus uint8
const ( const (
// AttributeStatusPassed binary, 1,2,4,8,16,32,etc
AttributeStatusPassed AttributeStatus = 0 AttributeStatusPassed AttributeStatus = 0
AttributeStatusFailedSmart AttributeStatus = 1 AttributeStatusFailedSmart AttributeStatus = 1
AttributeStatusWarningScrutiny AttributeStatus = 2 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 } func AttributeStatusHas(b, flag AttributeStatus) bool { return b&flag != 0 }
//go:generate stringer -type=DeviceStatus //go:generate stringer -type=DeviceStatus
// DeviceStatus bitwise flag, 1,2,4,8,16,32,etc
type DeviceStatus uint8 type DeviceStatus uint8
const ( const (
// DeviceStatusPassed binary, 1,2,4,8,16,32,etc
DeviceStatusPassed DeviceStatus = 0 DeviceStatusPassed DeviceStatus = 0
DeviceStatusFailedSmart DeviceStatus = 1 DeviceStatusFailedSmart DeviceStatus = 1
DeviceStatusFailedScrutiny DeviceStatus = 2 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 DeviceStatusClear(b, flag DeviceStatus) DeviceStatus { return b &^ flag }
func DeviceStatusToggle(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 } 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
)
+3 -3
View File
@@ -11,9 +11,6 @@ import (
type DeviceRepo interface { type DeviceRepo interface {
Close() error Close() error
//GetSettings()
//SaveSetting()
RegisterDevice(ctx context.Context, dev models.Device) error RegisterDevice(ctx context.Context, dev models.Device) error
GetDevices(ctx context.Context) ([]models.Device, error) GetDevices(ctx context.Context) ([]models.Device, error)
UpdateDevice(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (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) GetSummary(ctx context.Context) (map[string]*models.DeviceSummary, error)
GetSmartTemperatureHistory(ctx context.Context, durationKey string) (map[string][]measurements.SmartTemperature, 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
} }
@@ -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"`
}
@@ -62,7 +62,20 @@ func NewScrutinyRepository(appConfig config.Interface, globalLogger logrus.Field
// Gorm/SQLite setup // Gorm/SQLite setup
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
globalLogger.Infof("Trying to connect to scrutiny sqlite db: %s\n", appConfig.GetString("web.database.location")) 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. //TODO: figure out how to log database queries again.
//Logger: logger //Logger: logger
DisableForeignKeyConstraintWhenMigrating: true, DisableForeignKeyConstraintWhenMigrating: true,
@@ -450,3 +463,16 @@ func (sr *scrutinyRepository) lookupNestedDurationKeys(durationKey string) []str
} }
return []string{DURATION_KEY_WEEK} 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 ""
}
@@ -4,9 +4,11 @@ import (
"context" "context"
"errors" "errors"
"fmt" "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/m20201107210306"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220503120000" "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/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"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector" "github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
@@ -275,6 +277,77 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
return tx.Where("wwn = ?", "").Delete(&models.Device{}).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 { if err := m.Migrate(); err != nil {
@@ -282,6 +355,30 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
return err return err
} }
sr.logger.Infoln("Database migration completed successfully") 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 return nil
} }
@@ -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
}
-5
View File
@@ -1,5 +0,0 @@
package models
// Temperature Format
// Date Format
// Device History window
@@ -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"
}
+23
View File
@@ -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"`
}
+52 -25
View File
@@ -29,20 +29,22 @@ const NotifyFailureTypeSmartFailure = "SmartFailure"
const NotifyFailureTypeScrutinyFailure = "ScrutinyFailure" const NotifyFailureTypeScrutinyFailure = "ScrutinyFailure"
// ShouldNotify check if the error Message should be filtered (level mismatch or filtered_attributes) // ShouldNotify check if the error Message should be filtered (level mismatch or filtered_attributes)
func ShouldNotify(device models.Device, smartAttrs measurements.Smart, 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 // 1. check if the device is healthy
if device.DeviceStatus == pkg.DeviceStatusPassed { if device.DeviceStatus == pkg.DeviceStatusPassed {
return false return false
} }
//TODO: cannot check for warning notifyLevel yet.
// setup constants for comparison // setup constants for comparison
var requiredDeviceStatus pkg.DeviceStatus var requiredDeviceStatus pkg.DeviceStatus
var requiredAttrStatus pkg.AttributeStatus var requiredAttrStatus pkg.AttributeStatus
if notifyLevel == pkg.NotifyLevelFail { if statusThreshold == pkg.MetricsStatusThresholdBoth {
// either scrutiny or smart failures should trigger an email // either scrutiny or smart failures should trigger an email
requiredDeviceStatus = pkg.DeviceStatusSet(pkg.DeviceStatusFailedSmart, pkg.DeviceStatusFailedScrutiny) requiredDeviceStatus = pkg.DeviceStatusSet(pkg.DeviceStatusFailedSmart, pkg.DeviceStatusFailedScrutiny)
requiredAttrStatus = pkg.AttributeStatusSet(pkg.AttributeStatusFailedSmart, pkg.AttributeStatusFailedScrutiny) requiredAttrStatus = pkg.AttributeStatusSet(pkg.AttributeStatusFailedSmart, pkg.AttributeStatusFailedScrutiny)
} else if notifyLevel == pkg.NotifyLevelFailSmart { } else if statusThreshold == pkg.MetricsStatusThresholdSmart {
//only smart failures //only smart failures
requiredDeviceStatus = pkg.DeviceStatusFailedSmart requiredDeviceStatus = pkg.DeviceStatusFailedSmart
requiredAttrStatus = pkg.AttributeStatusFailedSmart 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) // 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) // 3. for any unfiltered attribute, store the failure reason (Smart or Scrutiny)
if notifyFilterAttributes == pkg.NotifyFilterAttributesCritical { if statusFilterAttributes == pkg.MetricsStatusFilterAttributesCritical {
hasFailingCriticalAttr := false hasFailingCriticalAttr := false
var statusFailingCrtiticalAttr pkg.AttributeStatus var statusFailingCriticalAttr pkg.AttributeStatus
for attrId, attrData := range smartAttrs.Attributes { for attrId, attrData := range smartAttrs.Attributes {
//find failing attribute //find failing attribute
@@ -64,7 +66,7 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLev
} }
// merge the status's of all critical attributes // 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 //found a failing attribute, see if its critical
if device.IsScsi() && thresholds.ScsiMetadata[attrId].Critical { if device.IsScsi() && thresholds.ScsiMetadata[attrId].Critical {
@@ -89,7 +91,7 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLev
return false return false
} else { } else {
// check if any of the critical attributes have a status that we're looking for // 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 { } 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 { type Payload struct {
DeviceType string `json:"device_type"` //ATA/SCSI/NVMe HostId string `json:"host_id,omitempty"` //host id (optional)
DeviceName string `json:"device_name"` //dev/sda DeviceType string `json:"device_type"` //ATA/SCSI/NVMe
DeviceSerial string `json:"device_serial"` //WDDJ324KSO DeviceName string `json:"device_name"` //dev/sda
Test bool `json:"test"` // false DeviceSerial string `json:"device_serial"` //WDDJ324KSO
Test bool `json:"test"` // false
//private, populated during init (marked as Public for JSON serialization) //private, populated during init (marked as Public for JSON serialization)
Date string `json:"date"` //populated by Send function. Date string `json:"date"` //populated by Send function.
@@ -113,8 +116,9 @@ type Payload struct {
Message string `json:"message"` 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{ payload := Payload{
HostId: strings.TrimSpace(device.HostId),
DeviceType: device.DeviceType, DeviceType: device.DeviceType,
DeviceName: device.DeviceName, DeviceName: device.DeviceName,
DeviceSerial: device.SerialNumber, DeviceSerial: device.SerialNumber,
@@ -122,7 +126,13 @@ func NewPayload(device models.Device, test bool) Payload {
} }
//validate that the Payload is populated //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.Date = sendDate.Format(time.RFC3339)
payload.FailureType = payload.GenerateFailureType(device.DeviceStatus) payload.FailureType = payload.GenerateFailureType(device.DeviceStatus)
payload.Subject = payload.GenerateSubject() payload.Subject = payload.GenerateSubject()
@@ -146,25 +156,39 @@ func (p *Payload) GenerateFailureType(deviceStatus pkg.DeviceStatus) string {
func (p *Payload) GenerateSubject() string { func (p *Payload) GenerateSubject() string {
//generate a detailed failure message //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 { func (p *Payload) GenerateMessage() string {
//generate a detailed failure message //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{}
if p.Test { messageParts = append(messageParts, fmt.Sprintf("Scrutiny SMART error notification for device: %s", p.DeviceName))
message = "TEST NOTIFICATION:\n" + message if len(p.HostId) > 0 {
messageParts = append(messageParts, fmt.Sprintf("Host Id: %s", p.HostId))
} }
return message 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 {
messageParts = append([]string{"TEST NOTIFICATION:"}, messageParts...)
}
return strings.Join(messageParts, "\n")
} }
func New(logger logrus.FieldLogger, appconfig config.Interface, device models.Device, test bool) Notify { 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_TYPE=%s", n.Payload.DeviceType))
copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_DEVICE_SERIAL=%s", n.Payload.DeviceSerial)) copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_DEVICE_SERIAL=%s", n.Payload.DeviceSerial))
copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_MESSAGE=%s", n.Payload.Message)) 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, "") err := utils.CmdExec(scriptPath, []string{}, "", copyEnv, "")
if err != nil { if err != nil {
n.Logger.Errorf("An error occurred while executing script %s: %v", scriptPath, err) n.Logger.Errorf("An error occurred while executing script %s: %v", scriptPath, err)
+118 -35
View File
@@ -1,11 +1,13 @@
package notify package notify
import ( import (
"fmt"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/models" "github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"testing" "testing"
"time"
) )
func TestShouldNotify_MustSkipPassingDevices(t *testing.T) { func TestShouldNotify_MustSkipPassingDevices(t *testing.T) {
@@ -15,56 +17,56 @@ func TestShouldNotify_MustSkipPassingDevices(t *testing.T) {
DeviceStatus: pkg.DeviceStatusPassed, DeviceStatus: pkg.DeviceStatusPassed,
} }
smartAttrs := measurements.Smart{} smartAttrs := measurements.Smart{}
notifyLevel := pkg.NotifyLevelFail statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.NotifyFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
//assert //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() t.Parallel()
//setup //setup
device := models.Device{ device := models.Device{
DeviceStatus: pkg.DeviceStatusFailedSmart, DeviceStatus: pkg.DeviceStatusFailedSmart,
} }
smartAttrs := measurements.Smart{} smartAttrs := measurements.Smart{}
notifyLevel := pkg.NotifyLevelFail statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.NotifyFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
//assert //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() t.Parallel()
//setup //setup
device := models.Device{ device := models.Device{
DeviceStatus: pkg.DeviceStatusFailedSmart, DeviceStatus: pkg.DeviceStatusFailedSmart,
} }
smartAttrs := measurements.Smart{} smartAttrs := measurements.Smart{}
notifyLevel := pkg.NotifyLevelFailSmart statusThreshold := pkg.MetricsStatusThresholdSmart
notifyFilterAttributes := pkg.NotifyFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
//assert //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() t.Parallel()
//setup //setup
device := models.Device{ device := models.Device{
DeviceStatus: pkg.DeviceStatusFailedSmart, DeviceStatus: pkg.DeviceStatusFailedSmart,
} }
smartAttrs := measurements.Smart{} smartAttrs := measurements.Smart{}
notifyLevel := pkg.NotifyLevelFailScrutiny statusThreshold := pkg.MetricsStatusThresholdScrutiny
notifyFilterAttributes := pkg.NotifyFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
//assert //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() t.Parallel()
//setup //setup
device := models.Device{ device := models.Device{
@@ -75,14 +77,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithCriticalAttrs(t *testin
Status: pkg.AttributeStatusFailedSmart, Status: pkg.AttributeStatusFailedSmart,
}, },
}} }}
notifyLevel := pkg.NotifyLevelFail statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.NotifyFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
//assert //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() t.Parallel()
//setup //setup
device := models.Device{ device := models.Device{
@@ -96,14 +98,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithMultipleCriticalAttrs(t
Status: pkg.AttributeStatusFailedScrutiny, Status: pkg.AttributeStatusFailedScrutiny,
}, },
}} }}
notifyLevel := pkg.NotifyLevelFail statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.NotifyFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
//assert //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() t.Parallel()
//setup //setup
device := models.Device{ device := models.Device{
@@ -114,14 +116,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithNoCriticalAttrs(t *test
Status: pkg.AttributeStatusFailedSmart, Status: pkg.AttributeStatusFailedSmart,
}, },
}} }}
notifyLevel := pkg.NotifyLevelFail statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.NotifyFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
//assert //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() t.Parallel()
//setup //setup
device := models.Device{ device := models.Device{
@@ -132,14 +134,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithNoFailingCriticalAttrs(
Status: pkg.AttributeStatusPassed, Status: pkg.AttributeStatusPassed,
}, },
}} }}
notifyLevel := pkg.NotifyLevelFail statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.NotifyFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
//assert //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() t.Parallel()
//setup //setup
device := models.Device{ device := models.Device{
@@ -153,9 +155,90 @@ func TestShouldNotify_NotifyFilterAttributesCritical_NotifyLevelFailSmart_WithCr
Status: pkg.AttributeStatusFailedScrutiny, Status: pkg.AttributeStatusFailedScrutiny,
}, },
}} }}
notifyLevel := pkg.NotifyLevelFailSmart statusThreshold := pkg.MetricsStatusThresholdSmart
notifyFilterAttributes := pkg.NotifyFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
//assert //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)
} }
@@ -8,7 +8,7 @@ import (
) )
func DeleteDevice(c *gin.Context) { func DeleteDevice(c *gin.Context) {
logger := c.MustGet("LOGGER").(logrus.FieldLogger) logger := c.MustGet("LOGGER").(*logrus.Entry)
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
err := deviceRepo.DeleteDevice(c, c.Param("wwn")) err := deviceRepo.DeleteDevice(c, c.Param("wwn"))
@@ -9,7 +9,7 @@ import (
) )
func GetDeviceDetails(c *gin.Context) { func GetDeviceDetails(c *gin.Context) {
logger := c.MustGet("LOGGER").(logrus.FieldLogger) logger := c.MustGet("LOGGER").(*logrus.Entry)
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
device, err := deviceRepo.GetDeviceDetails(c, c.Param("wwn")) device, err := deviceRepo.GetDeviceDetails(c, c.Param("wwn"))
@@ -8,7 +8,7 @@ import (
) )
func GetDevicesSummary(c *gin.Context) { func GetDevicesSummary(c *gin.Context) {
logger := c.MustGet("LOGGER").(logrus.FieldLogger) logger := c.MustGet("LOGGER").(*logrus.Entry)
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
summary, err := deviceRepo.GetSummary(c) summary, err := deviceRepo.GetSummary(c)
@@ -8,7 +8,7 @@ import (
) )
func GetDevicesSummaryTempHistory(c *gin.Context) { func GetDevicesSummaryTempHistory(c *gin.Context) {
logger := c.MustGet("LOGGER").(logrus.FieldLogger) logger := c.MustGet("LOGGER").(*logrus.Entry)
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
durationKey, exists := c.GetQuery("duration_key") durationKey, exists := c.GetQuery("duration_key")
@@ -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,
})
}
@@ -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. // 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) { func RegisterDevices(c *gin.Context) {
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
logger := c.MustGet("LOGGER").(logrus.FieldLogger) logger := c.MustGet("LOGGER").(*logrus.Entry)
var collectorDeviceWrapper models.DeviceWrapper var collectorDeviceWrapper models.DeviceWrapper
err := c.BindJSON(&collectorDeviceWrapper) err := c.BindJSON(&collectorDeviceWrapper)
@@ -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,
})
}
@@ -13,7 +13,7 @@ import (
// Send test notification // Send test notification
func SendTestNotification(c *gin.Context) { func SendTestNotification(c *gin.Context) {
appConfig := c.MustGet("CONFIG").(config.Interface) appConfig := c.MustGet("CONFIG").(config.Interface)
logger := c.MustGet("LOGGER").(logrus.FieldLogger) logger := c.MustGet("LOGGER").(*logrus.Entry)
testNotify := notify.New( testNotify := notify.New(
logger, logger,
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"fmt"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/config"
"github.com/analogj/scrutiny/webapp/backend/pkg/database" "github.com/analogj/scrutiny/webapp/backend/pkg/database"
@@ -13,7 +14,7 @@ import (
func UploadDeviceMetrics(c *gin.Context) { func UploadDeviceMetrics(c *gin.Context) {
//db := c.MustGet("DB").(*gorm.DB) //db := c.MustGet("DB").(*gorm.DB)
logger := c.MustGet("LOGGER").(logrus.FieldLogger) logger := c.MustGet("LOGGER").(*logrus.Entry)
appConfig := c.MustGet("CONFIG").(config.Interface) appConfig := c.MustGet("CONFIG").(config.Interface)
//influxWriteDb := c.MustGet("INFLUXDB_WRITE").(*api.WriteAPIBlocking) //influxWriteDb := c.MustGet("INFLUXDB_WRITE").(*api.WriteAPIBlocking)
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
@@ -67,7 +68,12 @@ func UploadDeviceMetrics(c *gin.Context) {
} }
//check for error //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 //send notifications
liveNotify := notify.New( liveNotify := notify.New(
+2 -2
View File
@@ -28,11 +28,11 @@ import (
var timeFormat = "02/Jan/2006:15:04:05 -0700" var timeFormat = "02/Jan/2006:15:04:05 -0700"
// Logger is the logrus logger handler // Logger is the logrus logger handler
func LoggerMiddleware(logger logrus.FieldLogger) gin.HandlerFunc { func LoggerMiddleware(logger *logrus.Entry) gin.HandlerFunc {
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err != nil { if err != nil {
hostname = "unknow" hostname = "unknown"
} }
return func(c *gin.Context) { return func(c *gin.Context) {
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"context"
"github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/config"
"github.com/analogj/scrutiny/webapp/backend/pkg/database" "github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -14,6 +15,14 @@ func RepositoryMiddleware(appConfig config.Interface, globalLogger logrus.FieldL
panic(err) 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() //TODO: determine where we can call defer deviceRepo.Close()
return func(c *gin.Context) { return func(c *gin.Context) {
c.Set("DEVICE_REPOSITORY", deviceRepo) c.Set("DEVICE_REPOSITORY", deviceRepo)
+9 -24
View File
@@ -9,18 +9,17 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg/web/middleware" "github.com/analogj/scrutiny/webapp/backend/pkg/web/middleware"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"io"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
) )
type AppEngine struct { type AppEngine struct {
Config config.Interface 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 := gin.New()
r.Use(middleware.LoggerMiddleware(logger)) r.Use(middleware.LoggerMiddleware(logger))
@@ -36,6 +35,10 @@ func (ae *AppEngine) Setup(logger logrus.FieldLogger) *gin.Engine {
api := base.Group("/api") api := base.Group("/api")
{ {
api.GET("/health", func(c *gin.Context) { 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{ c.JSON(http.StatusOK, gin.H{
"success": true, "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.GET("/device/:wwn/details", handler.GetDeviceDetails) //used by Details
api.DELETE("/device/:wwn", handler.DeleteDevice) //used by UI to delete device 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) 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. //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"))) { if !utils.FileExists(filepath.Dir(ae.Config.GetString("web.database.location"))) {
return errors.ConfigValidationError(fmt.Sprintf( return errors.ConfigValidationError(fmt.Sprintf(
@@ -102,7 +87,7 @@ func (ae *AppEngine) Start() error {
filepath.Dir(ae.Config.GetString("web.database.location")))) 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"))) return r.Run(fmt.Sprintf("%s:%s", ae.Config.GetString("web.listen.host"), ae.Config.GetString("web.listen.port")))
} }
+51 -23
View File
@@ -3,7 +3,9 @@ package web_test
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"github.com/analogj/scrutiny/webapp/backend/pkg" "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" 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"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector" "github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
@@ -89,6 +91,8 @@ func (suite *ServerTestSuite) TestHealthRoute() {
mockCtrl := gomock.NewController(suite.T()) mockCtrl := gomock.NewController(suite.T())
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) 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.database.location").Return(path.Join(parentPath, "scrutiny_test.db")).AnyTimes()
fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes() fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes()
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
@@ -111,7 +115,7 @@ func (suite *ServerTestSuite) TestHealthRoute() {
Config: fakeConfig, Config: fakeConfig,
} }
router := ae.Setup(logrus.New()) router := ae.Setup(logrus.WithField("test", suite.T().Name()))
//test //test
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -130,6 +134,8 @@ func (suite *ServerTestSuite) TestRegisterDevicesRoute() {
mockCtrl := gomock.NewController(suite.T()) mockCtrl := gomock.NewController(suite.T())
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) 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.database.location").Return(path.Join(parentPath, "scrutiny_test.db")).AnyTimes()
fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes() fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes()
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
@@ -150,7 +156,7 @@ func (suite *ServerTestSuite) TestRegisterDevicesRoute() {
ae := web.AppEngine{ ae := web.AppEngine{
Config: fakeConfig, 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") file, err := os.Open("testdata/register-devices-req.json")
require.NoError(suite.T(), err) require.NoError(suite.T(), err)
@@ -170,6 +176,8 @@ func (suite *ServerTestSuite) TestUploadDeviceMetricsRoute() {
mockCtrl := gomock.NewController(suite.T()) mockCtrl := gomock.NewController(suite.T())
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) 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.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
@@ -186,13 +194,14 @@ func (suite *ServerTestSuite) TestUploadDeviceMetricsRoute() {
} else { } else {
fakeConfig.EXPECT().GetString("web.influxdb.host").Return("localhost").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.host").Return("localhost").AnyTimes()
} }
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) 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{ ae := web.AppEngine{
Config: fakeConfig, 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") devicesfile, err := os.Open("testdata/register-devices-single-req.json")
require.NoError(suite.T(), err) require.NoError(suite.T(), err)
@@ -219,10 +228,13 @@ func (suite *ServerTestSuite) TestPopulateMultiple() {
mockCtrl := gomock.NewController(suite.T()) mockCtrl := gomock.NewController(suite.T())
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) 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().GetString("web.database.location").AnyTimes().Return("testdata/scrutiny_test.db")
fakeConfig.EXPECT().GetStringSlice("notify.urls").Return([]string{}).AnyTimes() fakeConfig.EXPECT().GetStringSlice("notify.urls").Return([]string{}).AnyTimes()
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) 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.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
@@ -243,7 +255,7 @@ func (suite *ServerTestSuite) TestPopulateMultiple() {
ae := web.AppEngine{ ae := web.AppEngine{
Config: fakeConfig, 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") devicesfile, err := os.Open("testdata/register-devices-req.json")
require.NoError(suite.T(), err) require.NoError(suite.T(), err)
@@ -319,6 +331,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_WebhookFailure() {
mockCtrl := gomock.NewController(suite.T()) mockCtrl := gomock.NewController(suite.T())
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) 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.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() 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().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"https://unroutable.domain.example.asdfghj"}) fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"https://unroutable.domain.example.asdfghj"})
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) 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 { if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
// when running test suite in github actions, we run an influxdb service as a sidecar. // 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{ ae := web.AppEngine{
Config: fakeConfig, Config: fakeConfig,
} }
router := ae.Setup(logrus.New()) router := ae.Setup(logrus.WithField("test", suite.T().Name()))
//test //test
wr := httptest.NewRecorder() wr := httptest.NewRecorder()
@@ -361,6 +376,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptFailure() {
mockCtrl := gomock.NewController(suite.T()) mockCtrl := gomock.NewController(suite.T())
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) 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.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() 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().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"script:///missing/path/on/disk"}) fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"script:///missing/path/on/disk"})
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) 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 { if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
// when running test suite in github actions, we run an influxdb service as a sidecar. // 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{ ae := web.AppEngine{
Config: fakeConfig, Config: fakeConfig,
} }
router := ae.Setup(logrus.New()) router := ae.Setup(logrus.WithField("test", suite.T().Name()))
//test //test
wr := httptest.NewRecorder() wr := httptest.NewRecorder()
@@ -403,6 +421,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptSuccess() {
mockCtrl := gomock.NewController(suite.T()) mockCtrl := gomock.NewController(suite.T())
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) 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.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() 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().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"script:///usr/bin/env"}) fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"script:///usr/bin/env"})
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) 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 { if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
// when running test suite in github actions, we run an influxdb service as a sidecar. // 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{ ae := web.AppEngine{
Config: fakeConfig, Config: fakeConfig,
} }
router := ae.Setup(logrus.New()) router := ae.Setup(logrus.WithField("test", suite.T().Name()))
//test //test
wr := httptest.NewRecorder() wr := httptest.NewRecorder()
@@ -445,6 +466,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ShoutrrrFailure() {
mockCtrl := gomock.NewController(suite.T()) mockCtrl := gomock.NewController(suite.T())
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) 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.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() 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().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"discord://invalidtoken@channel"}) fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"discord://invalidtoken@channel"})
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) 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 { if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
// when running test suite in github actions, we run an influxdb service as a sidecar. // 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{ ae := web.AppEngine{
Config: fakeConfig, Config: fakeConfig,
} }
router := ae.Setup(logrus.New()) router := ae.Setup(logrus.WithField("test", suite.T().Name()))
//test //test
wr := httptest.NewRecorder() wr := httptest.NewRecorder()
@@ -486,6 +510,8 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
mockCtrl := gomock.NewController(suite.T()) mockCtrl := gomock.NewController(suite.T())
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) 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.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath) fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes() 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().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{}) fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{})
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail) fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll) 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 { if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
// when running test suite in github actions, we run an influxdb service as a sidecar. // when running test suite in github actions, we run an influxdb service as a sidecar.
fakeConfig.EXPECT().GetString("web.influxdb.host").Return("influxdb").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.host").Return("influxdb").AnyTimes()
@@ -509,7 +537,7 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
ae := web.AppEngine{ ae := web.AppEngine{
Config: fakeConfig, 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") devicesfile, err := os.Open("testdata/register-devices-req-2.json")
require.NoError(suite.T(), err) require.NoError(suite.T(), err)
+17 -17
View File
@@ -1,22 +1,22 @@
import { NgModule, enableProdMode } from '@angular/core'; import {enableProdMode, NgModule} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser'; import {BrowserModule} from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import { ExtraOptions, PreloadAllModules, RouterModule } from '@angular/router'; import {ExtraOptions, PreloadAllModules, RouterModule} from '@angular/router';
import { APP_BASE_HREF } from '@angular/common'; import {APP_BASE_HREF} from '@angular/common';
import { MarkdownModule } from 'ngx-markdown'; import {MarkdownModule} from 'ngx-markdown';
import { TreoModule } from '@treo'; import {TreoModule} from '@treo';
import { TreoConfigModule } from '@treo/services/config'; import {ScrutinyConfigModule} from 'app/core/config/scrutiny-config.module';
import { TreoMockApiModule } from '@treo/lib/mock-api'; import {TreoMockApiModule} from '@treo/lib/mock-api';
import { CoreModule } from 'app/core/core.module'; import {CoreModule} from 'app/core/core.module';
import { appConfig } from 'app/core/config/app.config'; import {appConfig} from 'app/core/config/app.config';
import { mockDataServices } from 'app/data/mock'; import {mockDataServices} from 'app/data/mock';
import { LayoutModule } from 'app/layout/layout.module'; import {LayoutModule} from 'app/layout/layout.module';
import { AppComponent } from 'app/app.component'; import {AppComponent} from 'app/app.component';
import { appRoutes, getAppBaseHref } from 'app/app.routing'; import {appRoutes, getAppBaseHref} from 'app/app.routing';
const routerConfig: ExtraOptions = { const routerConfig: ExtraOptions = {
scrollPositionRestoration: 'enabled', scrollPositionRestoration: 'enabled',
preloadingStrategy : PreloadAllModules preloadingStrategy: PreloadAllModules
}; };
let dev = [ let dev = [
@@ -41,7 +41,7 @@ if (process.env.NODE_ENV === 'production') {
// Treo & Treo Mock API // Treo & Treo Mock API
TreoModule, TreoModule,
TreoConfigModule.forRoot(appConfig), ScrutinyConfigModule.forRoot(appConfig),
...dev, ...dev,
// Core // Core
@@ -10,19 +10,49 @@ export type DashboardSort = 'status' | 'title' | 'age'
export type TemperatureUnit = 'celsius' | 'fahrenheit' 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 * AppConfig interface. Update this interface to strictly type your config
* object. * object.
*/ */
export interface AppConfig { export interface AppConfig {
theme: Theme; theme?: Theme;
layout: Layout; layout?: Layout;
// Dashboard options // Dashboard options
dashboardDisplay: DashboardDisplay; dashboard_display?: DashboardDisplay;
dashboardSort: DashboardSort; 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". * "ConfigService".
*/ */
export const appConfig: AppConfig = { export const appConfig: AppConfig = {
theme : 'light', theme: 'light',
layout: 'material', layout: 'material',
dashboardDisplay: 'name', dashboard_display: 'name',
dashboardSort: 'status', dashboard_sort: 'status',
temperatureUnit: 'celsius', temperature_unit: 'celsius',
file_size_si_units: false,
metrics: {
notify_level: MetricsNotifyLevel.Fail,
status_filter_attributes: MetricsStatusFilterAttributes.All,
status_threshold: MetricsStatusThreshold.Both
}
}; };
@@ -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
}
]
};
}
}
@@ -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<AppConfig>;
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<AppConfig> {
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
}
}
@@ -1,21 +1,21 @@
<div [ngClass]="{ 'border-green': deviceSummary.device.device_status == 0 && deviceSummary.smart, <div [ngClass]="{ 'border-green': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'passed',
'border-red': deviceSummary.device.device_status != 0 }" 'border-red': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'failed' }"
class="relative flex flex-col flex-auto p-6 pr-3 pb-3 bg-card rounded border-l-4 shadow-md overflow-hidden"> class="relative flex flex-col flex-auto p-6 pr-3 pb-3 bg-card rounded border-l-4 shadow-md overflow-hidden">
<div class="absolute bottom-0 right-0 w-24 h-24 -m-6"> <div class="absolute bottom-0 right-0 w-24 h-24 -m-6">
<mat-icon class="icon-size-96 opacity-12 text-green" <mat-icon class="icon-size-96 opacity-12 text-green"
*ngIf="deviceSummary.device.device_status == 0 && deviceSummary.smart" *ngIf="deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'passed'"
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon> [svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
<mat-icon class="icon-size-96 opacity-12 text-red" <mat-icon class="icon-size-96 opacity-12 text-red"
*ngIf="deviceSummary.device.device_status != 0" *ngIf="deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'failed'"
[svgIcon]="'heroicons_outline:exclamation-circle'"></mat-icon> [svgIcon]="'heroicons_outline:exclamation-circle'"></mat-icon>
<mat-icon class="icon-size-96 opacity-12 text-yellow" <mat-icon class="icon-size-96 opacity-12 text-yellow"
*ngIf="!deviceSummary.smart" *ngIf="deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'unknown'"
[svgIcon]="'heroicons_outline:question-mark-circle'"></mat-icon> [svgIcon]="'heroicons_outline:question-mark-circle'"></mat-icon>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex flex-col"> <div class="flex flex-col">
<a [routerLink]="'/device/'+ deviceSummary.device.wwn" <a [routerLink]="'/device/'+ deviceSummary.device.wwn"
class="font-bold text-md text-secondary uppercase tracking-wider">{{deviceSummary.device | deviceTitle:config.dashboardDisplay}}</a> class="font-bold text-md text-secondary uppercase tracking-wider">{{deviceSummary.device | deviceTitle:config.dashboard_display}}</a>
<div [ngClass]="classDeviceLastUpdatedOn(deviceSummary)" class="font-medium text-sm" *ngIf="deviceSummary.smart"> <div [ngClass]="classDeviceLastUpdatedOn(deviceSummary)" class="font-medium text-sm" *ngIf="deviceSummary.smart">
Last Updated on {{deviceSummary.smart.collector_date | date:'MMMM dd, yyyy - HH:mm' }} Last Updated on {{deviceSummary.smart.collector_date | date:'MMMM dd, yyyy - HH:mm' }}
</div> </div>
@@ -46,17 +46,20 @@
<div class="flex flex-row flex-wrap mt-4 -mx-6"> <div class="flex flex-row flex-wrap mt-4 -mx-6">
<div class="flex flex-col mx-6 my-3 xs:w-full"> <div class="flex flex-col mx-6 my-3 xs:w-full">
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Status</div> <div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Status</div>
<div class="mt-2 font-medium text-3xl leading-none" *ngIf="deviceSummary.smart?.collector_date; else unknownStatus">{{ deviceStatusString(deviceSummary.device.device_status) | titlecase}}</div> <div class="mt-2 font-medium text-3xl leading-none"
*ngIf="deviceSummary.smart?.collector_date; else unknownStatus">{{ deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) | titlecase}}</div>
<ng-template #unknownStatus><div class="mt-2 font-medium text-3xl leading-none">No Data</div></ng-template> <ng-template #unknownStatus><div class="mt-2 font-medium text-3xl leading-none">No Data</div></ng-template>
</div> </div>
<div class="flex flex-col mx-6 my-3 xs:w-full"> <div class="flex flex-col mx-6 my-3 xs:w-full">
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Temperature</div> <div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Temperature</div>
<div class="mt-2 font-medium text-3xl leading-none" *ngIf="deviceSummary.smart?.collector_date; else unknownTemp">{{ deviceSummary.smart?.temp | temperature:config.temperatureUnit:true }}</div> <div class="mt-2 font-medium text-3xl leading-none"
*ngIf="deviceSummary.smart?.collector_date; else unknownTemp">{{ deviceSummary.smart?.temp | temperature:config.temperature_unit:true }}</div>
<ng-template #unknownTemp><div class="mt-2 font-medium text-3xl leading-none">--</div></ng-template> <ng-template #unknownTemp><div class="mt-2 font-medium text-3xl leading-none">--</div></ng-template>
</div> </div>
<div class="flex flex-col mx-6 my-3 xs:w-full"> <div class="flex flex-col mx-6 my-3 xs:w-full">
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Capacity</div> <div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Capacity</div>
<div class="mt-2 font-medium text-3xl leading-none">{{ deviceSummary.device.capacity | fileSize}}</div> <div
class="mt-2 font-medium text-3xl leading-none">{{ deviceSummary.device.capacity | fileSize:config.file_size_si_units}}</div>
</div> </div>
<div class="flex flex-col mx-6 my-3 xs:w-full"> <div class="flex flex-col mx-6 my-3 xs:w-full">
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Powered On</div> <div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Powered On</div>
@@ -9,26 +9,38 @@ import {MatMenuModule} from '@angular/material/menu';
import {TREO_APP_CONFIG} from '@treo/services/config/config.constants'; import {TREO_APP_CONFIG} from '@treo/services/config/config.constants';
import {DeviceSummaryModel} from 'app/core/models/device-summary-model'; import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
import * as moment from 'moment'; 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', () => { describe('DashboardDeviceComponent', () => {
let component: DashboardDeviceComponent; let component: DashboardDeviceComponent;
let fixture: ComponentFixture<DashboardDeviceComponent>; let fixture: ComponentFixture<DashboardDeviceComponent>;
const matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']); 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<HttpClient>;
beforeEach(async(() => { beforeEach(async(() => {
httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
configService = new ScrutinyConfigService(httpClientSpy, {});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
MatMenuModule, MatMenuModule,
SharedModule, SharedModule,
HttpClientTestingModule,
], ],
providers: [ providers: [
{provide: MatDialog, useValue: matDialogSpy}, {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] declarations: [DashboardDeviceComponent]
}) })
@@ -48,25 +60,53 @@ describe('DashboardDeviceComponent', () => {
describe('#classDeviceLastUpdatedOn()', () => { describe('#classDeviceLastUpdatedOn()', () => {
it('if non-zero device status, should be red', () => { 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 // component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel
expect(component.classDeviceLastUpdatedOn({ expect(component.classDeviceLastUpdatedOn({
device: { device: {
device_status: 2 device_status: 2,
} },
smart: {
collector_date: moment().subtract(13, 'days').toISOString()
},
} as DeviceSummaryModel)).toBe('text-red') } as DeviceSummaryModel)).toBe('text-red')
}); });
it('if non-zero device status, should be 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({ expect(component.classDeviceLastUpdatedOn({
device: { device: {
device_status: 2 device_status: 2
} },
smart: {
collector_date: moment().subtract(13, 'days').toISOString()
},
} as DeviceSummaryModel)).toBe('text-red') } as DeviceSummaryModel)).toBe('text-red')
}); });
it('if healthy device status and updated in the last two weeks, should be green', () => { 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({ expect(component.classDeviceLastUpdatedOn({
device: { device: {
device_status: 0 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', () => { 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({ expect(component.classDeviceLastUpdatedOn({
device: { device: {
device_status: 0 device_status: 0
@@ -90,7 +137,14 @@ describe('DashboardDeviceComponent', () => {
}); });
it('if healthy device status and updated more 1 month ago, should be red', () => { 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({ expect(component.classDeviceLastUpdatedOn({
device: { device: {
device_status: 0 device_status: 0
@@ -101,5 +155,4 @@ describe('DashboardDeviceComponent', () => {
} as DeviceSummaryModel)).toBe('text-red') } as DeviceSummaryModel)).toBe('text-red')
}); });
}) })
}); });
@@ -2,13 +2,14 @@ import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import * as moment from 'moment'; import * as moment from 'moment';
import {takeUntil} from 'rxjs/operators'; import {takeUntil} from 'rxjs/operators';
import {AppConfig} from 'app/core/config/app.config'; 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 {Subject} from 'rxjs';
import humanizeDuration from 'humanize-duration' import humanizeDuration from 'humanize-duration'
import {MatDialog} from '@angular/material/dialog'; import {MatDialog} from '@angular/material/dialog';
import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component'; import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component';
import {DeviceTitlePipe} from 'app/shared/device-title.pipe'; import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
import {DeviceSummaryModel} from 'app/core/models/device-summary-model'; import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
import {DeviceStatusPipe} from 'app/shared/device-status.pipe';
@Component({ @Component({
selector: 'app-dashboard-device', selector: 'app-dashboard-device',
@@ -18,7 +19,7 @@ import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
export class DashboardDeviceComponent implements OnInit { export class DashboardDeviceComponent implements OnInit {
constructor( constructor(
private _configService: TreoConfigService, private _configService: ScrutinyConfigService,
public dialog: MatDialog, public dialog: MatDialog,
) { ) {
// Set the private defaults // Set the private defaults
@@ -35,6 +36,8 @@ export class DashboardDeviceComponent implements OnInit {
readonly humanizeDuration = humanizeDuration; readonly humanizeDuration = humanizeDuration;
deviceStatusForModelWithThreshold = DeviceStatusPipe.deviceStatusForModelWithThreshold
ngOnInit(): void { ngOnInit(): void {
// Subscribe to config changes // Subscribe to config changes
this._configService.config$ this._configService.config$
@@ -50,9 +53,10 @@ export class DashboardDeviceComponent implements OnInit {
// ----------------------------------------------------------------------------------------------------- // -----------------------------------------------------------------------------------------------------
classDeviceLastUpdatedOn(deviceSummary: DeviceSummaryModel): string { 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 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)) { if (moment().subtract(14, 'days').isBefore(deviceSummary.smart.collector_date)) {
// this device was updated in the last 2 weeks. // this device was updated in the last 2 weeks.
return 'text-green' 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 { openDeleteDialog(): void {
const dialogRef = this.dialog.open(DashboardDeviceDeleteDialogComponent, { const dialogRef = this.dialog.open(DashboardDeviceDeleteDialogComponent, {
// width: '250px', // width: '250px',
data: { data: {
wwn: this.deviceWWN, wwn: this.deviceWWN,
title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboardDisplay) title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboard_display)
} }
}); });
@@ -37,69 +37,41 @@
<div class="flex flex-col mt-5 gt-md:flex-row"> <div class="flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3"> <mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
<mat-label>Temperature Display Unit</mat-label> <mat-label>Temperature</mat-label>
<mat-select [(ngModel)]="temperatureUnit"> <mat-select [(ngModel)]="temperatureUnit">
<mat-option value="celsius">Celsius</mat-option> <mat-option value="celsius">Celsius</mat-option>
<mat-option value="fahrenheit">Fahrenheit</mat-option> <mat-option value="fahrenheit">Fahrenheit</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
<mat-label>File Size</mat-label>
<mat-select [(ngModel)]="fileSizeSIUnits">
<mat-option [value]=true>SI Units (GB)</mat-option>
<mat-option [value]=false>Binary Units (GiB)</mat-option>
</mat-select>
</mat-form-field>
</div> </div>
<div class="flex"> <div class="flex flex-col mt-5 gt-md:flex-row">
<mat-tab-group mat-align-tabs="start"> <mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
<mat-tab label="Ata"> <mat-label>Device Status - Thresholds</mat-label>
<mat-select [(ngModel)]=statusThreshold>
<mat-option [value]=1>Smart</mat-option>
<mat-option [value]=2>Scrutiny</mat-option>
<mat-option [value]=3>Both</mat-option>
</mat-select>
</mat-form-field>
</div>
<div matTooltip="not yet implemented" class="flex flex-col mt-5 gt-md:flex-row"> <div class="flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-md:pr-3"> <mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
<mat-label class="text-hint">Critical Error Threshold</mat-label> <mat-label>Notify - Filter Attributes</mat-label>
<input disabled matInput [value]="'10%'"> <mat-select [(ngModel)]=statusFilterAttributes>
</mat-form-field> <mat-option [value]=0>All</mat-option>
<mat-form-field class="flex-auto gt-md:pl-3"> <mat-option [value]=1>Critical</mat-option>
<mat-label class="text-hint">Critical Warning Threshold</mat-label> </mat-select>
<input disabled matInput> </mat-form-field>
</mat-form-field>
</div>
<div matTooltip="not yet implemented" class="flex flex-col gt-md:flex-row">
<mat-form-field class="flex-auto gt-md:pr-3">
<mat-label class="text-hint">Error Threshold</mat-label>
<input disabled matInput [value]="'20%'">
</mat-form-field>
<mat-form-field class="flex-auto gt-md:pl-3">
<mat-label class="text-hint">Warning Threshold</mat-label>
<input disabled matInput [value]="'10%'">
</mat-form-field>
</div>
</mat-tab>
<mat-tab label="NVMe">
<div matTooltip="not yet implemented" class="flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-md:pr-3">
<mat-label class="text-hint">Critical Error Threshold</mat-label>
<input disabled matInput [value]="'enabled'">
</mat-form-field>
<mat-form-field class="flex-auto gt-md:pl-3">
<mat-label class="text-hint">Critical Warning Threshold</mat-label>
<input disabled matInput>
</mat-form-field>
</div>
</mat-tab>
<mat-tab label="SCSI">
<div matTooltip="not yet implemented" class="flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-md:pr-3">
<mat-label class="text-hint">Critical Error Threshold</mat-label>
<input disabled matInput [value]="'enabled'">
</mat-form-field>
<mat-form-field class="flex-auto gt-md:pl-3">
<mat-label class="text-hint">Critical Warning Threshold</mat-label>
<input disabled matInput>
</mat-form-field>
</div>
</mat-tab>
</mat-tab-group>
</div> </div>
</div> </div>
@@ -1,6 +1,14 @@
import {Component, OnInit} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {AppConfig} from 'app/core/config/app.config'; import {
import {TreoConfigService} from '@treo/services/config'; 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 {Subject} from 'rxjs';
import {takeUntil} from 'rxjs/operators'; import {takeUntil} from 'rxjs/operators';
@@ -14,13 +22,16 @@ export class DashboardSettingsComponent implements OnInit {
dashboardDisplay: string; dashboardDisplay: string;
dashboardSort: string; dashboardSort: string;
temperatureUnit: string; temperatureUnit: string;
fileSizeSIUnits: boolean;
theme: string; theme: string;
statusThreshold: number;
statusFilterAttributes: number;
// Private // Private
private _unsubscribeAll: Subject<any>; private _unsubscribeAll: Subject<any>;
constructor( constructor(
private _configService: TreoConfigService, private _configService: ScrutinyConfigService,
) { ) {
// Set the private defaults // Set the private defaults
this._unsubscribeAll = new Subject(); this._unsubscribeAll = new Subject();
@@ -33,21 +44,30 @@ export class DashboardSettingsComponent implements OnInit {
.subscribe((config: AppConfig) => { .subscribe((config: AppConfig) => {
// Store the config // Store the config
this.dashboardDisplay = config.dashboardDisplay; this.dashboardDisplay = config.dashboard_display;
this.dashboardSort = config.dashboardSort; this.dashboardSort = config.dashboard_sort;
this.temperatureUnit = config.temperatureUnit; this.temperatureUnit = config.temperature_unit;
this.fileSizeSIUnits = config.file_size_si_units;
this.theme = config.theme; this.theme = config.theme;
this.statusFilterAttributes = config.metrics.status_filter_attributes;
this.statusThreshold = config.metrics.status_threshold;
}); });
} }
saveSettings(): void { saveSettings(): void {
const newSettings = { const newSettings: AppConfig = {
dashboardDisplay: this.dashboardDisplay, dashboard_display: this.dashboardDisplay as DashboardDisplay,
dashboardSort: this.dashboardSort, dashboard_sort: this.dashboardSort as DashboardSort,
temperatureUnit: this.temperatureUnit, temperature_unit: this.temperatureUnit as TemperatureUnit,
theme: this.theme 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 this._configService.config = newSettings
console.log(`Saved Settings: ${JSON.stringify(newSettings)}`) console.log(`Saved Settings: ${JSON.stringify(newSettings)}`)
@@ -1,22 +1,21 @@
import { Component, Inject, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import {Component, Inject, OnDestroy, OnInit, ViewEncapsulation} from '@angular/core';
import { DOCUMENT } from '@angular/common'; import {DOCUMENT} from '@angular/common';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import {ActivatedRoute, NavigationEnd, Router} from '@angular/router';
import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import {MatSlideToggleChange} from '@angular/material/slide-toggle';
import { Subject } from 'rxjs'; import {Subject} from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators'; import {filter, takeUntil} from 'rxjs/operators';
import { TreoConfigService } from '@treo/services/config'; import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service';
import { TreoDrawerService } from '@treo/components/drawer'; import {TreoDrawerService} from '@treo/components/drawer';
import { Layout } from 'app/layout/layout.types'; import {Layout} from 'app/layout/layout.types';
import { AppConfig, Theme } from 'app/core/config/app.config'; import {AppConfig, Theme} from 'app/core/config/app.config';
@Component({ @Component({
selector : 'layout', selector: 'layout',
templateUrl : './layout.component.html', templateUrl: './layout.component.html',
styleUrls : ['./layout.component.scss'], styleUrls: ['./layout.component.scss'],
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class LayoutComponent implements OnInit, OnDestroy export class LayoutComponent implements OnInit, OnDestroy {
{
config: AppConfig; config: AppConfig;
layout: Layout; layout: Layout;
theme: Theme; theme: Theme;
@@ -29,14 +28,14 @@ export class LayoutComponent implements OnInit, OnDestroy
* Constructor * Constructor
* *
* @param {ActivatedRoute} _activatedRoute * @param {ActivatedRoute} _activatedRoute
* @param {TreoConfigService} _treoConfigService * @param {ScrutinyConfigService} _scrutinyConfigService
* @param {TreoDrawerService} _treoDrawerService * @param {TreoDrawerService} _treoDrawerService
* @param {DOCUMENT} _document * @param {DOCUMENT} _document
* @param {Router} _router * @param {Router} _router
*/ */
constructor( constructor(
private _activatedRoute: ActivatedRoute, private _activatedRoute: ActivatedRoute,
private _treoConfigService: TreoConfigService, private _scrutinyConfigService: ScrutinyConfigService,
private _treoDrawerService: TreoDrawerService, private _treoDrawerService: TreoDrawerService,
@Inject(DOCUMENT) private _document: any, @Inject(DOCUMENT) private _document: any,
private _router: Router private _router: Router
@@ -59,7 +58,7 @@ export class LayoutComponent implements OnInit, OnDestroy
ngOnInit(): void ngOnInit(): void
{ {
// Subscribe to config changes // Subscribe to config changes
this._treoConfigService.config$ this._scrutinyConfigService.config$
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe((config: AppConfig) => { .subscribe((config: AppConfig) => {
@@ -180,18 +179,17 @@ export class LayoutComponent implements OnInit, OnDestroy
* *
* @param layout * @param layout
*/ */
setLayout(layout: string): void setLayout(layout: Layout): void {
{
// Clear the 'layout' query param to allow layout changes // Clear the 'layout' query param to allow layout changes
this._router.navigate([], { this._router.navigate([], {
queryParams : { queryParams: {
layout: null layout: null
}, },
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}).then(() => { }).then(() => {
// Set the config // 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 setTheme(change: MatSlideToggleChange): void
{ {
this._treoConfigService.config = {theme: change.checked ? 'dark' : 'light'}; this._scrutinyConfigService.config = {theme: change.checked ? 'dark' : 'light'};
} }
} }
@@ -51,7 +51,11 @@
<div class="flex flex-wrap w-full" *ngFor="let hostId of hostGroups | keyvalue"> <div class="flex flex-wrap w-full" *ngFor="let hostId of hostGroups | keyvalue">
<h3 class="ml-4" *ngIf="hostId.key">{{hostId.key}}</h3> <h3 class="ml-4" *ngIf="hostId.key">{{hostId.key}}</h3>
<div class="flex flex-wrap w-full"> <div class="flex flex-wrap w-full">
<app-dashboard-device (deviceDeleted)="onDeviceDeleted($event)" class="flex gt-sm:w-1/2 min-w-80 p-4" *ngFor="let deviceSummary of (deviceSummariesForHostGroup(hostId.value) | deviceSort:config.dashboardSort:config.dashboardDisplay )" [deviceWWN]="deviceSummary.device.wwn" [deviceSummary]="deviceSummary"></app-dashboard-device> <app-dashboard-device (deviceDeleted)="onDeviceDeleted($event)"
class="flex gt-sm:w-1/2 min-w-80 p-4"
*ngFor="let deviceSummary of (deviceSummariesForHostGroup(hostId.value) | deviceSort:config.dashboard_sort:config.dashboard_display )"
[deviceWWN]="deviceSummary.device.wwn"
[deviceSummary]="deviceSummary"></app-dashboard-device>
</div> </div>
</div> </div>
@@ -14,7 +14,7 @@ import {DashboardService} from 'app/modules/dashboard/dashboard.service';
import {MatDialog} from '@angular/material/dialog'; import {MatDialog} from '@angular/material/dialog';
import {DashboardSettingsComponent} from 'app/layout/common/dashboard-settings/dashboard-settings.component'; import {DashboardSettingsComponent} from 'app/layout/common/dashboard-settings/dashboard-settings.component';
import {AppConfig} from 'app/core/config/app.config'; 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 {Router} from '@angular/router';
import {TemperaturePipe} from 'app/shared/temperature.pipe'; import {TemperaturePipe} from 'app/shared/temperature.pipe';
import {DeviceTitlePipe} from 'app/shared/device-title.pipe'; import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
@@ -43,13 +43,13 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
* Constructor * Constructor
* *
* @param {DashboardService} _dashboardService * @param {DashboardService} _dashboardService
* @param {TreoConfigService} _configService * @param {ScrutinyConfigService} _configService
* @param {MatDialog} dialog * @param {MatDialog} dialog
* @param {Router} router * @param {Router} router
*/ */
constructor( constructor(
private _dashboardService: DashboardService, private _dashboardService: DashboardService,
private _configService: TreoConfigService, private _configService: ScrutinyConfigService,
public dialog: MatDialog, public dialog: MatDialog,
private router: Router, private router: Router,
) )
@@ -150,7 +150,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
continue continue
} }
const deviceName = DeviceTitlePipe.deviceTitleWithFallback(deviceSummary.device, this.config.dashboardDisplay) const deviceName = DeviceTitlePipe.deviceTitleWithFallback(deviceSummary.device, this.config.dashboard_display)
const deviceSeriesMetadata = { const deviceSeriesMetadata = {
name: deviceName, name: deviceName,
@@ -161,7 +161,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
const newDate = new Date(tempHistory.date); const newDate = new Date(tempHistory.date);
deviceSeriesMetadata.data.push({ deviceSeriesMetadata.data.push({
x: newDate, x: newDate,
y: TemperaturePipe.formatTemperature(tempHistory.temp, this.config.temperatureUnit, false) y: TemperaturePipe.formatTemperature(tempHistory.temp, this.config.temperature_unit, false)
}) })
} }
deviceTemperatureSeries.push(deviceSeriesMetadata) deviceTemperatureSeries.push(deviceSeriesMetadata)
@@ -212,7 +212,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
y : { y : {
formatter: (value) => { 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 { openDialog(): void {
const dialogRef = this.dialog.open(DashboardSettingsComponent); const dialogRef = this.dialog.open(DashboardSettingsComponent, {width: '600px',});
dialogRef.afterClosed().subscribe(result => { dialogRef.afterClosed().subscribe(result => {
console.log(`Dialog result: ${result}`); console.log(`Dialog result: ${result}`);
@@ -4,7 +4,7 @@
<div class="flex items-center justify-between w-full my-4 px-4 xs:pr-0"> <div class="flex items-center justify-between w-full my-4 px-4 xs:pr-0">
<div class="mr-6"> <div class="mr-6">
<h2 class="m-0">Drive Details - {{device | deviceTitle:config.dashboardDisplay}} </h2> <h2 class="m-0">Drive Details - {{device | deviceTitle:config.dashboard_display}} </h2>
<div class="text-secondary tracking-tight">Dive into S.M.A.R.T data</div> <div class="text-secondary tracking-tight">Dive into S.M.A.R.T data</div>
</div> </div>
<!-- Action buttons --> <!-- Action buttons -->
@@ -17,7 +17,7 @@
<span class="ml-2">Export</span> <span class="ml-2">Export</span>
</button> </button>
<button class="ml-2 xs:hidden" <button class="ml-2 xs:hidden"
(click)="openDialog()" matTooltip="not yet implemented"
mat-stroked-button> mat-stroked-button>
<mat-icon class="icon-size-20 rotate-90 mirror" <mat-icon class="icon-size-20 rotate-90 mirror"
[svgIcon]="'tune'"></mat-icon> [svgIcon]="'tune'"></mat-icon>
@@ -56,12 +56,13 @@
<div *ngIf="device" class="my-2 col-span-2 lt-md:col-span-1"> <div *ngIf="device" class="my-2 col-span-2 lt-md:col-span-1">
<div> <div>
<span class="inline-flex items-center font-bold text-xs px-2 py-2px rounded-full tracking-wide uppercase" <span class="inline-flex items-center font-bold text-xs px-2 py-2px rounded-full tracking-wide uppercase"
[ngClass]="{'red-200': device?.device_status != 0, [ngClass]="{'red-200': deviceStatusForModelWithThreshold(device, !!smart_results, config.metrics.status_threshold) == 'failed',
'green-200': device?.device_status == 0}"> 'green-200': device?.device_status == 0}">
<span class="w-2 h-2 rounded-full mr-2" <span class="w-2 h-2 rounded-full mr-2"
[ngClass]="{'bg-red': device?.device_status != 0, [ngClass]="{'bg-red': device?.device_status != 0,
'bg-green': device?.device_status == 0}"></span> 'bg-green': device?.device_status == 0}"></span>
<span class="pr-2px leading-relaxed whitespace-no-wrap">{{device?.device_status | deviceStatus}}</span> <span
class="pr-2px leading-relaxed whitespace-no-wrap">{{device | deviceStatus:!!smart_results:config.metrics.status_threshold:true}}</span>
</span> </span>
</div> </div>
<div class="text-secondary text-md">Status</div> <div class="text-secondary text-md">Status</div>
@@ -106,7 +107,7 @@
<div class="text-secondary text-md">Firmware Version</div> <div class="text-secondary text-md">Firmware Version</div>
</div> </div>
<div class="my-2 col-span-2 lt-md:col-span-1"> <div class="my-2 col-span-2 lt-md:col-span-1">
<div>{{device?.capacity | fileSize}}</div> <div>{{device?.capacity | fileSize:config.file_size_si_units}}</div>
<div class="text-secondary text-md">Capacity</div> <div class="text-secondary text-md">Capacity</div>
</div> </div>
<div *ngIf="device?.rotational_speed" class="my-2 col-span-2 lt-md:col-span-1"> <div *ngIf="device?.rotational_speed" class="my-2 col-span-2 lt-md:col-span-1">
@@ -126,7 +127,7 @@
<div class="text-secondary text-md">Powered On</div> <div class="text-secondary text-md">Powered On</div>
</div> </div>
<div class="my-2 col-span-2 lt-md:col-span-1"> <div class="my-2 col-span-2 lt-md:col-span-1">
<div>{{smart_results[0]?.temp | temperature:config.temperatureUnit:true}}</div> <div>{{smart_results[0]?.temp | temperature:config.temperature_unit:true}}</div>
<div class="text-secondary text-md">Temperature</div> <div class="text-secondary text-md">Temperature</div>
</div> </div>
</div> </div>
@@ -8,7 +8,7 @@ import {MatDialog} from '@angular/material/dialog';
import {MatSort} from '@angular/material/sort'; import {MatSort} from '@angular/material/sort';
import {MatTableDataSource} from '@angular/material/table'; import {MatTableDataSource} from '@angular/material/table';
import {Subject} from 'rxjs'; 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 {animate, state, style, transition, trigger} from '@angular/animations';
import {formatDate} from '@angular/common'; import {formatDate} from '@angular/common';
import {takeUntil} from 'rxjs/operators'; 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 {SmartModel} from 'app/core/models/measurements/smart-model';
import {SmartAttributeModel} from 'app/core/models/measurements/smart-attribute-model'; import {SmartAttributeModel} from 'app/core/models/measurements/smart-attribute-model';
import {AttributeMetadataModel} from 'app/core/models/thresholds/attribute-metadata-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 // from Constants.go - these must match
const AttributeStatusPassed = 0 const AttributeStatusPassed = 0
@@ -44,13 +45,13 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
* *
* @param {DetailService} _detailService * @param {DetailService} _detailService
* @param {MatDialog} dialog * @param {MatDialog} dialog
* @param {TreoConfigService} _configService * @param {ScrutinyConfigService} _configService
* @param {string} locale * @param {string} locale
*/ */
constructor( constructor(
private _detailService: DetailService, private _detailService: DetailService,
public dialog: MatDialog, public dialog: MatDialog,
private _configService: TreoConfigService, private _configService: ScrutinyConfigService,
@Inject(LOCALE_ID) public locale: string @Inject(LOCALE_ID) public locale: string
) { ) {
// Set the private defaults // Set the private defaults
@@ -89,6 +90,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
readonly humanizeDuration = humanizeDuration; readonly humanizeDuration = humanizeDuration;
deviceStatusForModelWithThreshold = DeviceStatusPipe.deviceStatusForModelWithThreshold
// ----------------------------------------------------------------------------------------------------- // -----------------------------------------------------------------------------------------------------
// @ Lifecycle hooks // @ Lifecycle hooks
// ----------------------------------------------------------------------------------------------------- // -----------------------------------------------------------------------------------------------------
@@ -349,7 +351,9 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
attributes[attrId].chartData = [ attributes[attrId].chartData = [
{ {
name: 'chart-line-sparkline', name: 'chart-line-sparkline',
data: attrHistory // attrHistory needs to be reversed, so the newest data is on the right
// fixes #339
data: attrHistory.reverse()
} }
] ]
} }
@@ -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', () => { describe('DeviceStatusPipe', () => {
it('create an instance', () => { it('create an instance', () => {
const pipe = new DeviceStatusPipe(); const pipe = new DeviceStatusPipe();
expect(pipe).toBeTruthy(); 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)
});
});
});
}); });
@@ -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({ @Pipe({
name: 'deviceStatus' name: 'deviceStatus'
}) })
export class DeviceStatusPipe implements PipeTransform { export class DeviceStatusPipe implements PipeTransform {
transform(deviceStatusFlag: number): string {
if(deviceStatusFlag === 0){ static deviceStatusForModelWithThreshold(
return 'passed' deviceModel: DeviceModel,
} else if(deviceStatusFlag === 3){ hasSmartResults: boolean = true,
return 'failed: both' threshold: MetricsStatusThreshold = MetricsStatusThreshold.Both,
} else if(deviceStatusFlag === 2) { includeReason: boolean = false
return 'failed: scrutiny' ): string {
} else if(deviceStatusFlag === 1) { // no smart data, so treat the device status as unknown
return 'failed: smart' if (!hasSmartResults) {
} return 'unknown'
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)
}
} }
@@ -1,4 +1,4 @@
import { FileSizePipe } from './file-size.pipe'; import {FileSizePipe} from './file-size.pipe';
describe('FileSizePipe', () => { describe('FileSizePipe', () => {
it('create an instance', () => { it('create an instance', () => {
@@ -10,23 +10,61 @@ describe('FileSizePipe', () => {
const testCases = [ const testCases = [
{ {
'bytes': 1500, 'bytes': 1500,
'precision': undefined, 'si': false,
'result': '1 KB' 'result': '1.5 KiB'
},{ },
'bytes': 2_100_000_000, {
'precision': undefined,
'result': '2.0 GB',
},{
'bytes': 1500, 'bytes': 1500,
'precision': 2, 'si': true,
'result': '1.46 KB', '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) => { testCases.forEach((test, index) => {
it(`should correctly format bytes ${test.bytes}. (testcase: ${index + 1})`, () => { it(`should correctly format bytes ${test.bytes}. (testcase: ${index + 1})`, () => {
// test // test
const pipe = new FileSizePipe(); 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); expect(formatted).toEqual(test.result);
}); });
}) })
@@ -1,75 +1,27 @@
/** import {Pipe, PipeTransform} from '@angular/core';
* @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';
type unit = 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB'; @Pipe({name: 'fileSize'})
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' })
export class FileSizePipe implements PipeTransform { export class FileSizePipe implements PipeTransform {
private readonly units: unit[] = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
transform(bytes: number = 0, precision: number | unitPrecisionMap = defaultPrecisionMap): string { transform(bytes: number = 0, si = false, dp = 1): string {
if (isNaN(parseFloat(String(bytes))) || !isFinite(bytes)) return '?'; const thresh = si ? 1000 : 1024;
let unitIndex = 0; if (Math.abs(bytes) < thresh) {
return bytes + ' B';
while (bytes >= 1024) {
bytes /= 1024;
unitIndex++;
} }
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') { do {
return `${bytes.toFixed(+precision)} ${unit}`; bytes /= thresh;
} ++u;
return `${bytes.toFixed(precision[unit])} ${unit}`; } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
return bytes.toFixed(dp) + ' ' + units[u];
} }
} }