Compare commits

..

27 Commits

Author SHA1 Message Date
packagrio-bot 0f935ceb48 (v0.4.7) Automated packaging of release by Packagr 2022-05-25 15:04:55 +00:00
Jason Kulatunga f844a435fd fix error message. 2022-05-25 07:55:15 -07:00
Jason Kulatunga 3a970e7a27 Merge pull request #262 from AnalogJ/beta
pre-v0.4.7 release
2022-05-25 07:50:21 -07:00
Jason Kulatunga 307c2bcdef fix error message.
Simpler GormMigrateOptions.
2022-05-25 07:39:56 -07:00
Jason Kulatunga d62928aaae adding documentation for script based notifications. 2022-05-24 15:15:37 -07:00
Jason Kulatunga d08a1e3ef6 ignore retention policy errors during migration.
- fixes #256
2022-05-24 14:26:40 -07:00
Jason Kulatunga 2292041f9f never drop tables. 2022-05-24 10:00:41 -07:00
Jason Kulatunga 75e4bf1d6e added a helpful comment that the database migration might take a looong time. 2022-05-24 09:47:09 -07:00
Jason Kulatunga 97add04276 make sure the migration step runs with transactions, so that we can debug easier.
- related #256
2022-05-24 09:07:38 -07:00
Jason Kulatunga 1423f55d78 remove Power Cycle Count failure attribute for ATA drives. Unrealistic for consumer users (BackBlaze data is datacenter focused).
- fixed #31
2022-05-23 10:19:12 -07:00
Jason Kulatunga 46d0b70399 disable NVMe Scrutiny failures for "Numb Error Log Entries" attribute. More analysis needed for NVMe drives & their critical attributes.
- fixes #187
- fixes #247
2022-05-23 09:50:15 -07:00
Jason Kulatunga 168ca802d1 add support for specifying scheme for influxdb endpoint url (http vs https).
fixes #258
2022-05-23 09:34:16 -07:00
Jason Kulatunga 8c07e91f39 grey out and mark thresholds as not yet implemented. 2022-05-23 09:23:22 -07:00
Jason Kulatunga 7979950c3b fixing device sort and display title.
fixes #194
2022-05-23 08:49:51 -07:00
Jason Kulatunga 9846ba13e0 adding support for device sort in UI. 2022-05-23 08:19:58 -07:00
Jason Kulatunga 83839f7faf adding group by hostId support in dashboard.
fixes #151
2022-05-20 22:33:59 -07:00
Jason Kulatunga 85fa3b1f8f moved device summary info panel into isolated component. 2022-05-20 21:55:40 -07:00
Jason Kulatunga 4190f9a633 remove filter not implemented message. 2022-05-20 20:59:29 -07:00
Jason Kulatunga 743ce27d2e adding comment. 2022-05-20 20:59:29 -07:00
Jason Kulatunga 399a2450ff make sure we can change the temperature duration key for the chart. 2022-05-20 20:59:29 -07:00
Jason Kulatunga 934f16f0a5 persist settings across sessions (in local storage). 2022-05-20 20:59:29 -07:00
Jason Kulatunga 0aeb13c181 support custom display of devices by UUID/ID/Label & Scrutiny Name. (Does not persist).
Related #225
2022-05-20 20:59:29 -07:00
Jason Kulatunga 5899bf2026 started working on Dashboard UI sorting and naming 2022-05-20 20:59:29 -07:00
Jason Kulatunga 3b137964fc make sure we include the host id in the temp history label. 2022-05-20 20:59:29 -07:00
Jason Kulatunga 1bfdd0043f added a way to retrieve raw udev data. Can be used to retrieve disk label, UUID and "disk/by-id/*" device info.
Storing it in the database during device registration.
2022-05-20 20:59:29 -07:00
Jason Kulatunga 999c12748c added a way to retrieve raw udev data. Can be used to retrieve disk label, UUID and "disk/by-id/*" device info.
Storing it in the database during device registration.
2022-05-20 20:59:29 -07:00
Jason Kulatunga 6f283fd736 Update README.md 2022-05-20 10:25:02 -07:00
33 changed files with 729 additions and 236 deletions
+4 -2
View File
@@ -178,7 +178,9 @@ Scrutiny supports sending SMART device failure notifications via the following s
- Telegram
- Tulip
Check the `notify.urls` section of [example.scrutiny.yml](example.scrutiny.yaml) for more information and documentation for service specific setup.
Check the `notify.urls` section of [example.scrutiny.yml](example.scrutiny.yaml) for examples.
For more information and troubleshooting, see the [TROUBLESHOOTING_NOTIFICATIONS.md](./docs/TROUBLESHOOTING_NOTIFICATIONS.md) file
### Testing Notifications
@@ -239,7 +241,7 @@ scrutiny-collector-metrics run --debug --log-file /tmp/collector.log
| arm-6 | :white_check_mark: | |
| arm-7 | :white_check_mark: | web/collector only. see [#236](https://github.com/AnalogJ/scrutiny/issues/236) |
| arm64 | :white_check_mark: | :white_check_mark: |
| freebsd | :white_check_mark: | |
| freebsd | collector only. see [#238](https://github.com/AnalogJ/scrutiny/issues/238) | |
| macos-amd64 | | :white_check_mark: |
| macos-arm64 | | :white_check_mark: |
| windows-amd64 | :white_check_mark: | |
+1 -1
View File
@@ -13,7 +13,7 @@ func DevicePrefix() string {
func (d *Detect) Start() ([]models.Device, error) {
d.Shell = shell.Create()
// call the base/common functionality to get a list of devicess
// call the base/common functionality to get a list of devices
detectedDevices, err := d.SmartctlScan()
if err != nil {
return nil, err
+52
View File
@@ -1,9 +1,12 @@
package detect
import (
"fmt"
"github.com/analogj/scrutiny/collector/pkg/common/shell"
"github.com/analogj/scrutiny/collector/pkg/models"
"github.com/jaypipes/ghw"
"io/ioutil"
"path/filepath"
"strings"
)
@@ -22,6 +25,7 @@ func (d *Detect) Start() ([]models.Device, error) {
//inflate device info for detected devices.
for ndx, _ := range detectedDevices {
d.SmartCtlInfo(&detectedDevices[ndx]) //ignore errors.
populateUdevInfo(&detectedDevices[ndx]) //ignore errors.
}
return detectedDevices, nil
@@ -49,3 +53,51 @@ func (d *Detect) wwnFallback(detectedDevice *models.Device) {
//wwn must always be lowercase.
detectedDevice.WWN = strings.ToLower(detectedDevice.WWN)
}
// as discussed in
// - https://github.com/AnalogJ/scrutiny/issues/225
// - https://github.com/jaypipes/ghw/issues/59#issue-361915216
// udev exposes its data in a standardized way under /run/udev/data/....
func populateUdevInfo(detectedDevice *models.Device) error {
// Get device major:minor numbers
// `cat /sys/class/block/sda/dev`
devNo, err := ioutil.ReadFile(filepath.Join("/sys/class/block/", detectedDevice.DeviceName, "dev"))
if err != nil {
return err
}
// Look up block device in udev runtime database
// `cat /run/udev/data/b8:0`
udevID := "b" + strings.TrimSpace(string(devNo))
udevBytes, err := ioutil.ReadFile(filepath.Join("/run/udev/data/", udevID))
if err != nil {
return err
}
deviceMountPaths := []string{}
udevInfo := make(map[string]string)
for _, udevLine := range strings.Split(string(udevBytes), "\n") {
if strings.HasPrefix(udevLine, "E:") {
if s := strings.SplitN(udevLine[2:], "=", 2); len(s) == 2 {
udevInfo[s[0]] = s[1]
}
} else if strings.HasPrefix(udevLine, "S:") {
deviceMountPaths = append(deviceMountPaths, udevLine[2:])
}
}
//Set additional device information.
if deviceLabel, exists := udevInfo["ID_FS_LABEL"]; exists {
detectedDevice.DeviceLabel = deviceLabel
}
if deviceUUID, exists := udevInfo["ID_FS_UUID"]; exists {
detectedDevice.DeviceUUID = deviceUUID
}
if deviceSerialID, exists := udevInfo["ID_SERIAL"]; exists {
detectedDevice.DeviceSerialID = fmt.Sprintf("%s-%s", udevInfo["ID_BUS"], deviceSerialID)
}
return nil
}
+9 -2
View File
@@ -1,10 +1,13 @@
package models
type Device struct {
WWN string `json:"wwn"`
HostId string `json:"host_id"`
WWN string `json:"wwn"`
DeviceName string `json:"device_name"`
DeviceUUID string `json:"device_uuid"`
DeviceSerialID string `json:"device_serial_id"`
DeviceLabel string `json:"device_label"`
Manufacturer string `json:"manufacturer"`
ModelName string `json:"model_name"`
InterfaceType string `json:"interface_type"`
@@ -17,6 +20,10 @@ type Device struct {
SmartSupport bool `json:"smart_support"`
DeviceProtocol string `json:"device_protocol"` //protocol determines which smart attribute types are available (ATA, NVMe, SCSI)
DeviceType string `json:"device_type"` //device type is used for querying with -d/t flag, should only be used by collector.
// User provided metadata
Label string `json:"label"`
HostId string `json:"host_id"`
}
type DeviceWrapper struct {
+25
View File
@@ -0,0 +1,25 @@
# Notifications
As documented in [example.scrutiny.yaml](https://github.com/AnalogJ/scrutiny/blob/master/example.scrutiny.yaml#L59-L75)
there are multiple ways to configure notifications for Scrutiny.
Under the hood we use a library called [Shoutrrr](https://github.com/containrrr/shoutrrr) to send our notifications, and you should use their documentation if you run into
any issues: https://containrrr.dev/shoutrrr/services/overview/
# Script Notifications
While the Shoutrrr library supports many popular providers for sending notifications Scrutiny also supports a "script" based
notification system, allowing you to execute a custom script whenever a notification needs to be sent.
Data is provided to this script using the following environmental variables:
```
SCRUTINY_SUBJECT - eg. "Scrutiny SMART error (%s) detected on device: %s"
SCRUTINY_DATE
SCRUTINY_FAILURE_TYPE - EmailTest, SmartFail, ScrutinyFail
SCRUTINY_DEVICE_NAME - eg. /dev/sda
SCRUTINY_DEVICE_TYPE - ATA/SCSI/NVMe
SCRUTINY_DEVICE_SERIAL - eg. WDDJ324KSO
SCRUTINY_MESSAGE - eg. "Scrutiny SMART error notification for device: %s\nFailure Type: %s\nDevice Name: %s\nDevice Serial: %s\nDevice Type: %s\nDate: %s"
```
+1
View File
@@ -40,6 +40,7 @@ web:
# and store the information in the config file. If you 're re-using an existing influxdb installation, you'll need to provide
# the `token`
influxdb:
# scheme: 'http'
host: 0.0.0.0
port: 8086
# token: 'my-token'
+1
View File
@@ -39,6 +39,7 @@ func (c *configuration) Init() error {
c.SetDefault("notify.urls", []string{})
c.SetDefault("web.influxdb.scheme", "http")
c.SetDefault("web.influxdb.host", "localhost")
c.SetDefault("web.influxdb.port", "8086")
c.SetDefault("web.influxdb.org", "scrutiny")
@@ -5,6 +5,7 @@ import (
"time"
)
// Deprecated: m20220503120000.Device is deprecated, only used by db migrations
type Device struct {
//GORM attributes, see: http://gorm.io/docs/conventions.html
CreatedAt time.Time
@@ -0,0 +1,41 @@
package m20220509170100
import (
"github.com/analogj/scrutiny/webapp/backend/pkg"
"time"
)
type Device struct {
//GORM attributes, see: http://gorm.io/docs/conventions.html
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
WWN string `json:"wwn" gorm:"primary_key"`
DeviceName string `json:"device_name"`
DeviceUUID string `json:"device_uuid"`
DeviceSerialID string `json:"device_serial_id"`
DeviceLabel string `json:"device_label"`
Manufacturer string `json:"manufacturer"`
ModelName string `json:"model_name"`
InterfaceType string `json:"interface_type"`
InterfaceSpeed string `json:"interface_speed"`
SerialNumber string `json:"serial_number"`
Firmware string `json:"firmware"`
RotationSpeed int `json:"rotational_speed"`
Capacity int64 `json:"capacity"`
FormFactor string `json:"form_factor"`
SmartSupport bool `json:"smart_support"`
DeviceProtocol string `json:"device_protocol"` //protocol determines which smart attribute types are available (ATA, NVMe, SCSI)
DeviceType string `json:"device_type"` //device type is used for querying with -d/t flag, should only be used by collector.
// User provided metadata
Label string `json:"label"`
HostId string `json:"host_id"`
// Data set by Scrutiny
DeviceStatus pkg.DeviceStatus `json:"device_status"`
}
@@ -79,7 +79,7 @@ func NewScrutinyRepository(appConfig config.Interface, globalLogger logrus.Field
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Create a new client using an InfluxDB server base URL and an authentication token
influxdbUrl := fmt.Sprintf("http://%s:%s", appConfig.GetString("web.influxdb.host"), appConfig.GetString("web.influxdb.port"))
influxdbUrl := fmt.Sprintf("%s://%s:%s", appConfig.GetString("web.influxdb.scheme"), appConfig.GetString("web.influxdb.host"), appConfig.GetString("web.influxdb.port"))
globalLogger.Debugf("InfluxDB url: %s", influxdbUrl)
client := influxdb2.NewClient(influxdbUrl, appConfig.GetString("web.influxdb.token"))
@@ -18,7 +18,7 @@ import (
func (sr *scrutinyRepository) RegisterDevice(ctx context.Context, dev models.Device) error {
if err := sr.gormClient.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "wwn"}},
DoUpdates: clause.AssignmentColumns([]string{"host_id", "device_name", "device_type"}),
DoUpdates: clause.AssignmentColumns([]string{"host_id", "device_name", "device_type", "device_uuid", "device_serial_id", "device_label"}),
}).Create(&dev).Error; err != nil {
return err
}
@@ -2,14 +2,18 @@ package database
import (
"context"
"errors"
"fmt"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20201107210306"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220503120000"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220509170100"
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"github.com/go-gormigrate/gormigrate/v2"
"github.com/influxdata/influxdb-client-go/v2/api/http"
_ "github.com/jinzhu/gorm/dialects/sqlite"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"strconv"
"time"
@@ -22,9 +26,12 @@ import (
func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
sr.logger.Infoln("Database migration starting")
sr.logger.Infoln("Database migration starting. Please wait, this process may take a long time....")
m := gormigrate.New(sr.gormClient, gormigrate.DefaultOptions, []*gormigrate.Migration{
gormMigrateOptions := gormigrate.DefaultOptions
gormMigrateOptions.UseTransaction = true
m := gormigrate.New(sr.gormClient, gormMigrateOptions, []*gormigrate.Migration{
{
ID: "20201107210306", // v0.3.13 (pre-influxdb schema). 9fac3c6308dc6cb6cd5bbc43a68cd93e8fb20b87
Migrate: func(tx *gorm.DB) error {
@@ -38,16 +45,6 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
&m20201107210306.SmartNvmeAttribute{},
)
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable(
&m20201107210306.Device{},
&m20201107210306.Smart{},
&m20201107210306.SmartAtaAttribute{},
&m20201107210306.SmartNvmeAttribute{},
&m20201107210306.SmartNvmeAttribute{},
"self_tests",
)
},
},
{
ID: "20220503113100", // backwards compatible - influxdb schema
@@ -137,7 +134,7 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
smartTags,
smartFields,
postSmartResults.Date, ctx)
if err != nil {
if ignorePastRetentionPolicyError(err) != nil {
return err
}
@@ -147,7 +144,7 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
tempTags,
tempFields,
postSmartResults.Date, ctx)
if err != nil {
if ignorePastRetentionPolicyError(err) != nil {
return err
}
}
@@ -166,7 +163,7 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
smartFields,
postSmartResults.Date, ctx)
if err != nil {
if ignorePastRetentionPolicyError(err) != nil {
return err
}
@@ -176,7 +173,7 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
tempTags,
tempFields,
postSmartResults.Date, ctx)
if err != nil {
if ignorePastRetentionPolicyError(err) != nil {
return err
}
}
@@ -194,7 +191,7 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
smartTags,
smartFields,
postSmartResults.Date, ctx)
if err != nil {
if ignorePastRetentionPolicyError(err) != nil {
return err
}
@@ -204,7 +201,7 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
tempTags,
tempFields,
postSmartResults.Date, ctx)
if err != nil {
if ignorePastRetentionPolicyError(err) != nil {
return err
}
}
@@ -221,7 +218,7 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
smartTags,
smartFields,
postSmartResults.Date, ctx)
if err != nil {
if ignorePastRetentionPolicyError(err) != nil {
return err
}
@@ -231,7 +228,7 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
tempTags,
tempFields,
postSmartResults.Date, ctx)
if err != nil {
if ignorePastRetentionPolicyError(err) != nil {
return err
}
}
@@ -257,20 +254,44 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
return err
}
//migrate the device database to the current version
//migrate the device database
return tx.AutoMigrate(m20220503120000.Device{})
},
},
{
ID: "m20220509170100", // addl udev device data
Migrate: func(tx *gorm.DB) error {
//migrate the device database.
// adding addl columns (device_label, device_uuid, device_serial_id)
return tx.AutoMigrate(m20220509170100.Device{})
},
},
})
if err := m.Migrate(); err != nil {
sr.logger.Errorf("Database migration failed with error: %w", err)
sr.logger.Errorf("Database migration 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("Database migration completed successfully")
return nil
}
// helpers
//When adding data to influxdb, an error may be returned if the data point is outside the range of the retention policy.
//This function will ignore retention policy errors, and allow the migration to continue.
func ignorePastRetentionPolicyError(err error) error {
var influxDbWriteError *http.Error
if errors.As(err, &influxDbWriteError) {
if influxDbWriteError.StatusCode == 422 {
log.Infoln("ignoring error: attempted to writePoint past retention period duration")
return nil
}
}
return err
}
// Deprecated
func m20201107210306_FromPreInfluxDBTempCreatePostInfluxDBTemp(preDevice m20201107210306.Device, preSmartResult m20201107210306.Smart) (error, measurements.SmartTemperature) {
//extract temperature data for every datapoint
+4
View File
@@ -21,6 +21,10 @@ type Device struct {
WWN string `json:"wwn" gorm:"primary_key"`
DeviceName string `json:"device_name"`
DeviceUUID string `json:"device_uuid"`
DeviceSerialID string `json:"device_serial_id"`
DeviceLabel string `json:"device_label"`
Manufacturer string `json:"manufacturer"`
ModelName string `json:"model_name"`
InterfaceType string `json:"interface_type"`
@@ -171,7 +171,7 @@ func (sm *Smart) ProcessNvmeSmartInfo(nvmeSmartHealthInformationLog collector.Nv
"power_on_hours": (&SmartNvmeAttribute{AttributeId: "power_on_hours", Value: nvmeSmartHealthInformationLog.PowerOnHours, Threshold: -1}).PopulateAttributeStatus(),
"unsafe_shutdowns": (&SmartNvmeAttribute{AttributeId: "unsafe_shutdowns", Value: nvmeSmartHealthInformationLog.UnsafeShutdowns, Threshold: -1}).PopulateAttributeStatus(),
"media_errors": (&SmartNvmeAttribute{AttributeId: "media_errors", Value: nvmeSmartHealthInformationLog.MediaErrors, Threshold: 0}).PopulateAttributeStatus(),
"num_err_log_entries": (&SmartNvmeAttribute{AttributeId: "num_err_log_entries", Value: nvmeSmartHealthInformationLog.NumErrLogEntries, Threshold: 0}).PopulateAttributeStatus(),
"num_err_log_entries": (&SmartNvmeAttribute{AttributeId: "num_err_log_entries", Value: nvmeSmartHealthInformationLog.NumErrLogEntries, Threshold: -1}).PopulateAttributeStatus(),
"warning_temp_time": (&SmartNvmeAttribute{AttributeId: "warning_temp_time", Value: nvmeSmartHealthInformationLog.WarningTempTime, Threshold: -1}).PopulateAttributeStatus(),
"critical_comp_time": (&SmartNvmeAttribute{AttributeId: "critical_comp_time", Value: nvmeSmartHealthInformationLog.CriticalCompTime, Threshold: -1}).PopulateAttributeStatus(),
}
@@ -445,50 +445,6 @@ var AtaMetadata = map[int]AtaAttributeMetadata{
Ideal: ObservedThresholdIdealLow,
Critical: false,
Description: "This attribute indicates the count of full hard disk power on/off cycles.",
ObservedThresholds: []ObservedThreshold{
{
Low: 0,
High: 13,
AnnualFailureRate: 0.019835987118930823,
ErrorInterval: []float64{0.016560870164523494, 0.023569242386797896},
},
{
Low: 13,
High: 26,
AnnualFailureRate: 0.038210930067894826,
ErrorInterval: []float64{0.03353859179329295, 0.0433520775718649},
},
{
Low: 26,
High: 39,
AnnualFailureRate: 0.11053528307302571,
ErrorInterval: []float64{0.09671061589521368, 0.1257816678419765},
},
{
Low: 39,
High: 52,
AnnualFailureRate: 0.16831189443375036,
ErrorInterval: []float64{0.1440976510675928, 0.19543066007594895},
},
{
Low: 52,
High: 65,
AnnualFailureRate: 0.20630344262550107,
ErrorInterval: []float64{0.1693965932069108, 0.2488633537247856},
},
{
Low: 65,
High: 78,
AnnualFailureRate: 0.1030972634140512,
ErrorInterval: []float64{0.06734655535304743, 0.15106137807407605},
},
{
Low: 78,
High: 91,
AnnualFailureRate: 0.12354840389522469,
ErrorInterval: []float64{0.06578432170016109, 0.21127153335749593},
},
},
},
13: {
ID: 13,
+1 -1
View File
@@ -2,4 +2,4 @@ package version
// VERSION is the app-global version string, which will be replaced with a
// new value during packaging
const VERSION = "0.4.6"
const VERSION = "0.4.7"
+10 -1
View File
@@ -93,6 +93,7 @@ func (suite *ServerTestSuite) TestHealthRoute() {
fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes()
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.scheme").Return("http").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.port").Return("8086").AnyTimes()
fakeConfig.EXPECT().IsSet("web.influxdb.token").Return(true).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
@@ -132,6 +133,7 @@ func (suite *ServerTestSuite) TestRegisterDevicesRoute() {
fakeConfig.EXPECT().GetString("web.database.location").Return(path.Join(parentPath, "scrutiny_test.db")).AnyTimes()
fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes()
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.scheme").Return("http").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.port").Return("8086").AnyTimes()
fakeConfig.EXPECT().IsSet("web.influxdb.token").Return(true).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
@@ -171,6 +173,7 @@ func (suite *ServerTestSuite) TestUploadDeviceMetricsRoute() {
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.scheme").Return("http").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.port").Return("8086").AnyTimes()
fakeConfig.EXPECT().IsSet("web.influxdb.token").Return(true).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
@@ -219,6 +222,7 @@ func (suite *ServerTestSuite) TestPopulateMultiple() {
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.scheme").Return("http").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.port").Return("8086").AnyTimes()
fakeConfig.EXPECT().IsSet("web.influxdb.token").Return(true).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
@@ -314,6 +318,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_WebhookFailure() {
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.scheme").Return("http").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.port").Return("8086").AnyTimes()
fakeConfig.EXPECT().IsSet("web.influxdb.token").Return(true).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
@@ -352,6 +357,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptFailure() {
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.scheme").Return("http").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.port").Return("8086").AnyTimes()
fakeConfig.EXPECT().IsSet("web.influxdb.token").Return(true).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
@@ -390,6 +396,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptSuccess() {
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.scheme").Return("http").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.port").Return("8086").AnyTimes()
fakeConfig.EXPECT().IsSet("web.influxdb.token").Return(true).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
@@ -428,6 +435,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ShoutrrrFailure() {
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.scheme").Return("http").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.port").Return("8086").AnyTimes()
fakeConfig.EXPECT().IsSet("web.influxdb.token").Return(true).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
@@ -465,6 +473,7 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.scheme").Return("http").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.port").Return("8086").AnyTimes()
fakeConfig.EXPECT().IsSet("web.influxdb.token").Return(true).AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
@@ -509,5 +518,5 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
//assert
require.Equal(suite.T(), "a4c8e8ed-11a0-4c97-9bba-306440f1b944", deviceSummary.Data.Summary["a4c8e8ed-11a0-4c97-9bba-306440f1b944"].Device.WWN)
require.Equal(suite.T(), pkg.DeviceStatusFailedScrutiny, deviceSummary.Data.Summary["a4c8e8ed-11a0-4c97-9bba-306440f1b944"].Device.DeviceStatus)
require.Equal(suite.T(), pkg.DeviceStatusPassed, deviceSummary.Data.Summary["a4c8e8ed-11a0-4c97-9bba-306440f1b944"].Device.DeviceStatus)
}
@@ -3,6 +3,8 @@ import { BehaviorSubject, Observable } from 'rxjs';
import * as _ from 'lodash';
import { TREO_APP_CONFIG } from '@treo/services/config/config.constants';
const SCRUTINY_CONFIG_LOCAL_STORAGE_KEY = 'scrutiny';
@Injectable({
providedIn: 'root'
})
@@ -10,14 +12,22 @@ export class TreoConfigService
{
// Private
private _config: BehaviorSubject<any>;
/**
* Constructor
*/
constructor(@Inject(TREO_APP_CONFIG) config: any)
constructor(@Inject(TREO_APP_CONFIG) defaultConfig: any)
{
let currentScrutinyConfig = defaultConfig
let localConfigStr = localStorage.getItem(SCRUTINY_CONFIG_LOCAL_STORAGE_KEY)
if(localConfigStr){
//check localstorage for a value
let localConfig = JSON.parse(localConfigStr)
currentScrutinyConfig = localConfig
}
// Set the private defaults
this._config = new BehaviorSubject(config);
this._config = new BehaviorSubject(currentScrutinyConfig);
}
// -----------------------------------------------------------------------------------------------------
@@ -27,15 +37,20 @@ export class TreoConfigService
/**
* Setter and getter for config
*/
//Setter
set config(value: any)
{
// Merge the new config over to the current config
const config = _.merge({}, this._config.getValue(), value);
//Store the config in localstorage
localStorage.setItem(SCRUTINY_CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
// Execute the observable
this._config.next(config);
}
//Getter
get config$(): Observable<any>
{
return this._config.asObservable();
@@ -11,6 +11,11 @@ export interface AppConfig
{
theme: Theme;
layout: Layout;
// Dashboard options
dashboardDisplay: string;
dashboardSort: string;
}
/**
@@ -23,6 +28,9 @@ export interface AppConfig
*/
export const appConfig: AppConfig = {
theme : "light",
layout: "material"
layout: "material",
dashboardDisplay: "name",
dashboardSort: "status",
};
@@ -11,6 +11,9 @@ export const summary = {
"DeletedAt": null,
"wwn": "0x5000c500673e6b5f",
"device_name": "sdg",
"device_label": "14TB-WD-DRIVE2",
"device_uuid": "",
"device_serial_id": "ata-ST6000DX000-1H217Z-Z4DXXXXX",
"manufacturer": "ATA",
"model_name": "ST6000DX000-1H217Z",
"interface_type": "SCSI",
@@ -35,6 +38,9 @@ export const summary = {
"DeletedAt": null,
"wwn": "0x5000cca252c859cc",
"device_name": "sdd",
"device_label": "14TB-WD-DRIVE1",
"device_uuid": "806cf4bc-d160-4d96-8ee9-3ab7cf2a2e1f",
"device_serial_id": "ata-WDC_WD80EFAX-68LHPN0-7SGLXXXXX",
"manufacturer": "ATA",
"model_name": "WDC_WD80EFAX-68LHPN0",
"interface_type": "SCSI",
@@ -68,6 +74,9 @@ export const summary = {
"DeletedAt": null,
"wwn": "0x5000cca264eb01d7",
"device_name": "sdb",
"device_label": "14TB-WD-DRIVE5",
"device_uuid": "8125ec6d-a7e4-4950-ac84-72d6a4d67128",
"device_serial_id": "ata-WDC_WD140EDFZ-11A0VA0-9RK1XXXXX",
"manufacturer": "ATA",
"model_name": "WDC_WD140EDFZ-11A0VA0",
"interface_type": "SCSI",
@@ -101,6 +110,9 @@ export const summary = {
"DeletedAt": null,
"wwn": "0x5000cca264ebc248",
"device_name": "sde",
"device_label": "14TB-WD-DRIVE3",
"device_uuid": "9eb60cde-d6d0-4172-b520-b241a6a5477f",
"device_serial_id": "ata-WDC_WD140EDFZ-11A0VA0-9RK3XXXXX",
"manufacturer": "ATA",
"model_name": "WDC_WD140EDFZ-11A0VA0",
"interface_type": "SCSI",
@@ -125,6 +137,9 @@ export const summary = {
"DeletedAt": null,
"wwn": "0x5000cca264ec3183",
"device_name": "sdc",
"device_label": "14TB-WD-DRIVE6",
"device_uuid": "e1378723-7861-49b9-8e01-0bd063f0ecdd",
"device_serial_id": "ata-WDC_WD140EDFZ-11A0VA0-9RK4XXXXX",
"manufacturer": "ATA",
"model_name": "WDC_WD140EDFZ-11A0VA0",
"interface_type": "SCSI",
@@ -138,7 +153,7 @@ export const summary = {
"device_protocol": "",
"device_type": "",
"label": "",
"host_id": "",
"host_id": "custom host id",
"device_status": 1
},
"smart": {
@@ -542,6 +557,9 @@ export const summary = {
"DeletedAt": null,
"wwn": "0x50014ee20b2a72a9",
"device_name": "sdf",
"device_label": "8.0TB-WD-4",
"device_uuid": "fc684dcc-aa2f-44f3-a958-d302dc7dd46d",
"device_serial_id": "ata-WDC_WD60EFRX-68MYMN1-WXL1HXXXXX",
"manufacturer": "ATA",
"model_name": "WDC_WD60EFRX-68MYMN1",
"interface_type": "SCSI",
@@ -566,6 +584,9 @@ export const summary = {
"DeletedAt": null,
"wwn": "0x5002538e40a22954",
"device_name": "sda",
"device_label": "",
"device_uuid": "",
"device_serial_id": "ata-Samsung_SSD_860_EVO_500GB-S3YZNB0KBXXXXXX",
"manufacturer": "ATA",
"model_name": "Samsung_SSD_860_EVO_500GB",
"interface_type": "SCSI",
@@ -0,0 +1,61 @@
<div [ngClass]="{ 'border-green': deviceSummary.device.device_status == 0 && deviceSummary.smart,
'border-red': deviceSummary.device.device_status != 0 }"
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">
<mat-icon class="icon-size-96 opacity-12 text-green"
*ngIf="deviceSummary.device.device_status == 0 && deviceSummary.smart"
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
<mat-icon class="icon-size-96 opacity-12 text-red"
*ngIf="deviceSummary.device.device_status != 0"
[svgIcon]="'heroicons_outline:exclamation-circle'"></mat-icon>
<mat-icon class="icon-size-96 opacity-12 text-yellow"
*ngIf="!deviceSummary.smart"
[svgIcon]="'heroicons_outline:question-mark-circle'"></mat-icon>
</div>
<div class="flex items-center">
<div class="flex flex-col">
<a [routerLink]="'/device/'+ deviceSummary.device.wwn"
class="font-bold text-md text-secondary uppercase tracking-wider">{{deviceTitle(deviceSummary.device)}}</a>
<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' }}
</div>
</div>
<div class="ml-auto" *ngIf="deviceSummary.device">
<button mat-icon-button
[matMenuTriggerFor]="previousStatementMenu">
<mat-icon [svgIcon]="'more_vert'"></mat-icon>
</button>
<mat-menu #previousStatementMenu="matMenu">
<a mat-menu-item [routerLink]="'/device/'+ deviceSummary.device.wwn">
<span class="flex items-center">
<mat-icon class="icon-size-20 mr-3"
[svgIcon]="'payment'"></mat-icon>
<span>View Details</span>
</span>
</a>
</mat-menu>
</div>
</div>
<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="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>
<ng-template #unknownStatus><div class="mt-2 font-medium text-3xl leading-none">No Data</div></ng-template>
</div>
<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="mt-2 font-medium text-3xl leading-none" *ngIf="deviceSummary.smart?.collector_date; else unknownTemp">{{ deviceSummary.smart?.temp }}°C</div>
<ng-template #unknownTemp><div class="mt-2 font-medium text-3xl leading-none">--</div></ng-template>
</div>
<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="mt-2 font-medium text-3xl leading-none">{{ deviceSummary.device.capacity | fileSize}}</div>
</div>
<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="mt-2 font-medium text-3xl leading-none" *ngIf="deviceSummary.smart?.power_on_hours; else unknownPoweredOn">{{ humanizeDuration(deviceSummary.smart?.power_on_hours * 60 * 60 * 1000, { round: true, largest: 1, units: ['y', 'd', 'h'] }) }}</div>
<ng-template #unknownPoweredOn><div class="mt-2 font-medium text-3xl leading-none">--</div></ng-template>
</div>
</div>
</div>
@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardDeviceComponent } from './dashboard-device.component';
describe('DashboardDeviceComponent', () => {
let component: DashboardDeviceComponent;
let fixture: ComponentFixture<DashboardDeviceComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ DashboardDeviceComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DashboardDeviceComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,115 @@
import {Component, Input, OnInit} from '@angular/core';
import * as moment from "moment";
import {takeUntil} from "rxjs/operators";
import {AppConfig} from "app/core/config/app.config";
import {TreoConfigService} from "@treo/services/config";
import {Subject} from "rxjs";
import humanizeDuration from 'humanize-duration'
@Component({
selector: 'app-dashboard-device',
templateUrl: './dashboard-device.component.html',
styleUrls: ['./dashboard-device.component.scss']
})
export class DashboardDeviceComponent implements OnInit {
@Input() deviceSummary: any;
@Input() deviceWWN: string;
config: AppConfig;
private _unsubscribeAll: Subject<any>;
constructor(
private _configService: TreoConfigService,
) {
// Set the private defaults
this._unsubscribeAll = new Subject();
}
ngOnInit(): void {
// Subscribe to config changes
this._configService.config$
.pipe(takeUntil(this._unsubscribeAll))
.subscribe((config: AppConfig) => {
this.config = config;
});
}
// -----------------------------------------------------------------------------------------------------
// @ Public methods
// -----------------------------------------------------------------------------------------------------
classDeviceLastUpdatedOn(deviceSummary){
if (deviceSummary.device.device_status !== 0) {
return 'text-red' // if the device has failed, always highlight in red
} else if(deviceSummary.device.device_status === 0 && deviceSummary.smart){
if(moment().subtract(14, 'd').isBefore(deviceSummary.smart.collector_date)){
// this device was updated in the last 2 weeks.
return 'text-green'
} else if(moment().subtract(1, 'm').isBefore(deviceSummary.smart.collector_date)){
// this device was updated in the last month
return 'text-yellow'
} else{
// last updated more than a month ago.
return 'text-red'
}
} else {
return ''
}
}
deviceTitle(disk){
console.log(`Displaying Device ${disk.wwn} with: ${this.config.dashboardDisplay}`)
let titleParts = []
if (disk.host_id) titleParts.push(disk.host_id)
//add device identifier (fallback to generated device name)
titleParts.push(deviceDisplayTitle(disk, this.config.dashboardDisplay) || deviceDisplayTitle(disk, 'name'))
return titleParts.join(' - ')
}
deviceStatusString(deviceStatus){
if(deviceStatus == 0){
return "passed"
} else {
return "failed"
}
}
readonly humanizeDuration = humanizeDuration;
}
export function deviceDisplayTitle(disk, titleType: string){
let titleParts = []
switch(titleType){
case 'name':
titleParts.push(`/dev/${disk.device_name}`)
if (disk.device_type && disk.device_type != 'scsi' && disk.device_type != 'ata'){
titleParts.push(disk.device_type)
}
titleParts.push(disk.model_name)
break;
case 'serial_id':
if(!disk.device_serial_id) return ''
titleParts.push(`/by-id/${disk.device_serial_id}`)
break;
case 'uuid':
if(!disk.device_uuid) return ''
titleParts.push(`/by-uuid/${disk.device_uuid}`)
break;
case 'label':
if(disk.label){
titleParts.push(disk.label)
} else if(disk.device_label){
titleParts.push(`/by-label/${disk.device_label}`)
}
break;
}
return titleParts.join(' - ')
}
@@ -0,0 +1,52 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { Overlay } from '@angular/cdk/overlay';
import { MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { SharedModule } from 'app/shared/shared.module';
import {DashboardDeviceComponent} from 'app/layout/common/dashboard-device/dashboard-device.component'
import { MatDialogModule } from "@angular/material/dialog";
import { MatButtonToggleModule} from "@angular/material/button-toggle";
import {MatTabsModule} from "@angular/material/tabs";
import {MatSliderModule} from "@angular/material/slider";
import {MatSlideToggleModule} from "@angular/material/slide-toggle";
import {MatTooltipModule} from "@angular/material/tooltip";
import {dashboardRoutes} from "../../../modules/dashboard/dashboard.routing";
import {MatDividerModule} from "@angular/material/divider";
import {MatMenuModule} from "@angular/material/menu";
import {MatProgressBarModule} from "@angular/material/progress-bar";
import {MatSortModule} from "@angular/material/sort";
import {MatTableModule} from "@angular/material/table";
import {NgApexchartsModule} from "ng-apexcharts";
import {DashboardSettingsModule} from "../dashboard-settings/dashboard-settings.module";
@NgModule({
declarations: [
DashboardDeviceComponent
],
imports : [
RouterModule.forChild([]),
RouterModule.forChild(dashboardRoutes),
MatButtonModule,
MatDividerModule,
MatTooltipModule,
MatIconModule,
MatMenuModule,
MatProgressBarModule,
MatSortModule,
MatTableModule,
NgApexchartsModule,
SharedModule,
],
exports : [
DashboardDeviceComponent,
],
providers : []
})
export class DashboardDeviceModule
{
}
@@ -1,14 +1,23 @@
<h2 mat-dialog-title>Scrutiny Settings</h2>
<mat-dialog-content class="mat-typography">
<form class="flex flex-col p-8 pb-0 overflow-hidden">
<div class="flex flex-col gt-xs:flex-row">
<mat-form-field class="flex-auto gt-xs:pr-3">
<div class="flex flex-col p-8 pb-0 overflow-hidden">
<div class="flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
<mat-label>Display Title</mat-label>
<mat-select [(ngModel)]="dashboardDisplay">
<mat-option value="name">Name</mat-option>
<mat-option value="serial_id">Serial ID</mat-option>
<mat-option value="uuid">UUID</mat-option>
<mat-option value="label">Label</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pl-3">
<mat-label>Sort By</mat-label>
<mat-select [value]="'status'">
<mat-option value="status">Status</mat-option>
<mat-option value="name" disabled>Name</mat-option>
<mat-option value="label" disabled>Label</mat-option>
<mat-select [(ngModel)]="dashboardSort">
<mat-option value="status">Status</mat-option>
<mat-option value="title">Title</mat-option>
</mat-select>
</mat-form-field>
</div>
@@ -17,61 +26,61 @@
<mat-tab-group mat-align-tabs="start">
<mat-tab label="Ata">
<div class="flex flex-col mt-5 gt-md:flex-row">
<div matTooltip="not yet implemented" class="gray-200 flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-md:pr-3">
<mat-label>Critical Error Threshold</mat-label>
<input matInput [value]="'10%'">
<input disabled matInput [value]="'10%'">
</mat-form-field>
<mat-form-field class="flex-auto gt-md:pl-3">
<mat-label>Critical Warning Threshold</mat-label>
<input matInput>
<input disabled matInput>
</mat-form-field>
</div>
<div class="flex flex-col gt-md:flex-row">
<div matTooltip="not yet implemented" class="gray-200 flex flex-col gt-md:flex-row">
<mat-form-field class="flex-auto gt-md:pr-3">
<mat-label>Error Threshold</mat-label>
<input matInput [value]="'20%'">
<input disabled matInput [value]="'20%'">
</mat-form-field>
<mat-form-field class="flex-auto gt-md:pl-3">
<mat-label>Warning Threshold</mat-label>
<input matInput [value]="'10%'">
<input disabled matInput [value]="'10%'">
</mat-form-field>
</div>
</mat-tab>
<mat-tab label="NVMe">
<div class="flex flex-col mt-5 gt-md:flex-row">
<div matTooltip="not yet implemented" class="gray-200 flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-md:pr-3">
<mat-label>Critical Error Threshold</mat-label>
<input matInput [value]="'enabled'">
<input disabled matInput [value]="'enabled'">
</mat-form-field>
<mat-form-field class="flex-auto gt-md:pl-3">
<mat-label>Critical Warning Threshold</mat-label>
<input matInput>
<input disabled matInput>
</mat-form-field>
</div>
</mat-tab>
<mat-tab label="SCSI">
<div class="flex flex-col mt-5 gt-md:flex-row">
<div matTooltip="not yet implemented" class="gray-200 flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-md:pr-3">
<mat-label>Critical Error Threshold</mat-label>
<input matInput [value]="'enabled'">
<input disabled matInput [value]="'enabled'">
</mat-form-field>
<mat-form-field class="flex-auto gt-md:pl-3">
<mat-label>Critical Warning Threshold</mat-label>
<input matInput>
<input disabled matInput>
</mat-form-field>
</div>
</mat-tab>
</mat-tab-group>
</div>
</form>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button mat-button matTooltip="not yet implemented" [mat-dialog-close]="true" cdkFocusInitial>Save</button>
<button mat-button mat-dialog-close (click)="saveSettings()" cdkFocusInitial>Save</button>
</mat-dialog-actions>
@@ -1,4 +1,8 @@
import { Component, OnInit } from '@angular/core';
import {AppConfig} from 'app/core/config/app.config';
import { TreoConfigService } from '@treo/services/config';
import {Subject} from "rxjs";
import {takeUntil} from "rxjs/operators";
@Component({
selector: 'app-dashboard-settings',
@@ -7,10 +11,41 @@ import { Component, OnInit } from '@angular/core';
})
export class DashboardSettingsComponent implements OnInit {
constructor() { }
dashboardDisplay: string;
dashboardSort: string;
// Private
private _unsubscribeAll: Subject<any>;
constructor(
private _configService: TreoConfigService,
) {
// Set the private defaults
this._unsubscribeAll = new Subject();
}
ngOnInit(): void {
// Subscribe to config changes
this._configService.config$
.pipe(takeUntil(this._unsubscribeAll))
.subscribe((config: AppConfig) => {
// Store the config
this.dashboardDisplay = config.dashboardDisplay;
this.dashboardSort = config.dashboardSort;
});
}
saveSettings(): void {
var newSettings = {
dashboardDisplay: this.dashboardDisplay,
dashboardSort: this.dashboardSort
}
this._configService.config = newSettings
console.log(`Saved Settings: ${JSON.stringify(newSettings)}`)
}
formatLabel(value: number) {
return value;
}
@@ -47,71 +47,15 @@
</div>
</div>
<div class="flex flex-wrap w-full">
<div *ngFor="let summary of data.data.summary | keyvalue" class="flex gt-sm:w-1/2 min-w-80 p-4">
<div [ngClass]="{ 'border-green': summary.value.device.device_status == 0 && summary.value.smart,
'border-red': summary.value.device.device_status != 0 }"
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">
<mat-icon class="icon-size-96 opacity-12 text-green"
*ngIf="summary.value.device.device_status == 0 && summary.value.smart"
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
<mat-icon class="icon-size-96 opacity-12 text-red"
*ngIf="summary.value.device.device_status != 0"
[svgIcon]="'heroicons_outline:exclamation-circle'"></mat-icon>
<mat-icon class="icon-size-96 opacity-12 text-yellow"
*ngIf="!summary.value.smart"
[svgIcon]="'heroicons_outline:question-mark-circle'"></mat-icon>
</div>
<div class="flex items-center">
<div class="flex flex-col">
<a [routerLink]="'/device/'+ summary.value.device.wwn"
class="font-bold text-md text-secondary uppercase tracking-wider">{{deviceTitle(summary.value.device)}}</a>
<div [ngClass]="classDeviceLastUpdatedOn(summary.value)" class="font-medium text-sm" *ngIf="summary.value.smart">
Last Updated on {{summary.value.smart.collector_date | date:'MMMM dd, yyyy - HH:mm' }}
</div>
</div>
<div class="ml-auto" *ngIf="summary.value.device">
<button mat-icon-button
[matMenuTriggerFor]="previousStatementMenu">
<mat-icon [svgIcon]="'more_vert'"></mat-icon>
</button>
<mat-menu #previousStatementMenu="matMenu">
<a mat-menu-item [routerLink]="'/device/'+ summary.value.device.wwn">
<span class="flex items-center">
<mat-icon class="icon-size-20 mr-3"
[svgIcon]="'payment'"></mat-icon>
<span>View Details</span>
</span>
</a>
</mat-menu>
</div>
</div>
<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="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Status</div>
<div class="mt-2 font-medium text-3xl leading-none" *ngIf="summary.value.smart?.collector_date; else unknownStatus">{{ deviceStatusString(summary.value.device.device_status) | titlecase}}</div>
<ng-template #unknownStatus><div class="mt-2 font-medium text-3xl leading-none">No Data</div></ng-template>
</div>
<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="mt-2 font-medium text-3xl leading-none" *ngIf="summary.value.smart?.collector_date; else unknownTemp">{{ summary.value.smart?.temp }}°C</div>
<ng-template #unknownTemp><div class="mt-2 font-medium text-3xl leading-none">--</div></ng-template>
</div>
<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="mt-2 font-medium text-3xl leading-none">{{ summary.value.device.capacity | fileSize}}</div>
</div>
<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="mt-2 font-medium text-3xl leading-none" *ngIf="summary.value.smart?.power_on_hours; else unknownPoweredOn">{{ humanizeDuration(summary.value.smart?.power_on_hours * 60 * 60 * 1000, { round: true, largest: 1, units: ['y', 'd', 'h'] }) }}</div>
<ng-template #unknownPoweredOn><div class="mt-2 font-medium text-3xl leading-none">--</div></ng-template>
</div>
</div>
</div>
<div class="flex flex-wrap w-full" *ngFor="let hostId of hostGroups | keyvalue">
<h3 class="ml-4" *ngIf="hostId.key">{{hostId.key}}</h3>
<div class="flex flex-wrap w-full">
<app-dashboard-device 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>
</div>
</div>
<!-- Drive Temperatures -->
<div class="flex flex-auto w-full min-w-80 h-90 p-4">
<div class="flex flex-col flex-auto bg-card shadow-md rounded overflow-hidden">
@@ -123,22 +67,22 @@
</div>
<div>
<button class="h-8 min-h-8 px-2"
matTooltip="not yet implemented"
mat-button
[matMenuTriggerFor]="tempRangeMenu">
<span class="font-medium text-sm text-hint">1 week</span>
<span class="font-medium text-sm text-hint">{{tempDurationKey}}</span>
</button>
<mat-menu #tempRangeMenu="matMenu">
<button mat-menu-item>1 month</button>
<button mat-menu-item>12 months</button>
<button mat-menu-item>all time</button>
<button (click)="changeSummaryTempDuration('forever')" mat-menu-item>forever</button>
<button (click)="changeSummaryTempDuration('year')" mat-menu-item>year</button>
<button (click)="changeSummaryTempDuration('month')" mat-menu-item>month</button>
<button (click)="changeSummaryTempDuration('week')" mat-menu-item>week</button>
</mat-menu>
</div>
</div>
</div>
<div class="flex flex-col flex-auto">
<apx-chart *ngIf="temperatureOptions" class="flex-auto w-full h-full"
<apx-chart #tempChart *ngIf="temperatureOptions" class="flex-auto w-full h-full"
[chart]="temperatureOptions.chart"
[colors]="temperatureOptions.colors"
[fill]="temperatureOptions.fill"
@@ -3,12 +3,14 @@ import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ApexOptions } from 'ng-apexcharts';
import {ApexOptions, ChartComponent} from 'ng-apexcharts';
import { DashboardService } from 'app/modules/dashboard/dashboard.service';
import * as moment from "moment";
import {MatDialog} from '@angular/material/dialog';
import { DashboardSettingsComponent } from 'app/layout/common/dashboard-settings/dashboard-settings.component';
import humanizeDuration from 'humanize-duration'
import {deviceDisplayTitle} from "app/layout/common/dashboard-device/dashboard-device.component";
import {AppConfig} from "app/core/config/app.config";
import {TreoConfigService} from "@treo/services/config";
import {Router} from "@angular/router";
@Component({
selector : 'example',
@@ -20,10 +22,14 @@ import humanizeDuration from 'humanize-duration'
export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
{
data: any;
hostGroups: { [hostId: string]: string[] } = {}
temperatureOptions: ApexOptions;
tempDurationKey: string = "forever"
config: AppConfig;
// Private
private _unsubscribeAll: Subject<any>;
@ViewChild("tempChart", { static: false }) tempChart: ChartComponent;
/**
* Constructor
@@ -32,7 +38,9 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
*/
constructor(
private _smartService: DashboardService,
public dialog: MatDialog
private _configService: TreoConfigService,
public dialog: MatDialog,
private router: Router,
)
{
// Set the private defaults
@@ -49,6 +57,28 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
*/
ngOnInit(): void
{
// Subscribe to config changes
this._configService.config$
.pipe(takeUntil(this._unsubscribeAll))
.subscribe((config: AppConfig) => {
//check if the old config and the new config do not match.
let oldConfig = JSON.stringify(this.config)
let newConfig = JSON.stringify(config)
if(oldConfig != newConfig){
console.log(`Configuration updated: ${newConfig} vs ${oldConfig}`)
// Store the config
this.config = config;
if(oldConfig){
console.log("reloading component...")
this.refreshComponent()
}
}
});
// Get the data
this._smartService.data$
.pipe(takeUntil(this._unsubscribeAll))
@@ -57,6 +87,15 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
// Store the data
this.data = data;
//generate group data.
for(let wwn in this.data.data.summary){
let hostid = this.data.data.summary[wwn].device.host_id
let hostDeviceList = this.hostGroups[hostid] || []
hostDeviceList.push(wwn)
this.hostGroups[hostid] = hostDeviceList
}
console.log(this.hostGroups)
// Prepare the chart data
this._prepareChartData();
});
@@ -81,6 +120,14 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
// -----------------------------------------------------------------------------------------------------
// @ Private methods
// -----------------------------------------------------------------------------------------------------
private refreshComponent(){
let currentUrl = this.router.url;
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.router.onSameUrlNavigation = 'reload';
this.router.navigate([currentUrl]);
}
private _deviceDataTemperatureSeries() {
var deviceTemperatureSeries = []
@@ -91,8 +138,11 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
if (!deviceSummary.temp_history){
continue
}
let deviceName = this.deviceTitle(deviceSummary.device)
var deviceSeriesMetadata = {
name: `/dev/${deviceSummary.device.device_name}`,
name: deviceName,
data: []
}
@@ -164,6 +214,26 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
// @ Public methods
// -----------------------------------------------------------------------------------------------------
deviceTitle(disk){
console.log(`Displaying Device ${disk.wwn} with: ${this.config.dashboardDisplay}`)
let titleParts = []
if (disk.host_id) titleParts.push(disk.host_id)
//add device identifier (fallback to generated device name)
titleParts.push(deviceDisplayTitle(disk, this.config.dashboardDisplay) || deviceDisplayTitle(disk, 'name'))
return titleParts.join(' - ')
}
deviceSummariesForHostGroup(hostGroupWWNs: string[]) {
let deviceSummaries = []
for(let wwn of hostGroupWWNs){
deviceSummaries.push(this.data.data.summary[wwn])
}
return deviceSummaries
}
openDialog() {
const dialogRef = this.dialog.open(DashboardSettingsComponent);
@@ -172,48 +242,29 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
});
}
deviceTitle(disk){
let title = []
/*
if (disk.host_id) title.push(disk.host_id)
DURATION_KEY_WEEK = "week"
DURATION_KEY_MONTH = "month"
DURATION_KEY_YEAR = "year"
DURATION_KEY_FOREVER = "forever"
*/
title.push(`/dev/${disk.device_name}`)
changeSummaryTempDuration(durationKey: string){
this.tempDurationKey = durationKey
if (disk.device_type && disk.device_type != 'scsi' && disk.device_type != 'ata'){
title.push(disk.device_type)
}
this._smartService.getSummaryTempData(durationKey)
.subscribe((data) => {
title.push(disk.model_name)
// given a list of device temp history, override the data in the "summary" object.
for(const wwn in this.data.data.summary) {
// console.log(`Updating ${wwn}, length: ${this.data.data.summary[wwn].temp_history.length}`)
this.data.data.summary[wwn].temp_history = data.data.temp_history[wwn] || []
}
return title.join(' - ')
}
deviceStatusString(deviceStatus){
if(deviceStatus == 0){
return "passed"
} else {
return "failed"
}
}
classDeviceLastUpdatedOn(deviceSummary){
if (deviceSummary.device.device_status !== 0) {
return 'text-red' // if the device has failed, always highlight in red
} else if(deviceSummary.device.device_status === 0 && deviceSummary.smart){
if(moment().subtract(14, 'd').isBefore(deviceSummary.smart.collector_date)){
// this device was updated in the last 2 weeks.
return 'text-green'
} else if(moment().subtract(1, 'm').isBefore(deviceSummary.smart.collector_date)){
// this device was updated in the last month
return 'text-yellow'
} else{
// last updated more than a month ago.
return 'text-red'
}
} else {
return ''
}
// Prepare the chart series data
this.tempChart.updateSeries(this._deviceDataTemperatureSeries())
});
}
/**
@@ -227,6 +278,4 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
return item.id || index;
}
readonly humanizeDuration = humanizeDuration;
}
@@ -13,12 +13,13 @@ import { MatTableModule } from '@angular/material/table';
import { NgApexchartsModule } from 'ng-apexcharts';
import { MatTooltipModule } from '@angular/material/tooltip'
import { DashboardSettingsModule } from "app/layout/common/dashboard-settings/dashboard-settings.module";
import { DashboardDeviceModule } from "app/layout/common/dashboard-device/dashboard-device.module";
@NgModule({
declarations: [
DashboardComponent
],
imports : [
imports: [
RouterModule.forChild(dashboardRoutes),
MatButtonModule,
MatDividerModule,
@@ -30,7 +31,8 @@ import { DashboardSettingsModule } from "app/layout/common/dashboard-settings/da
MatTableModule,
NgApexchartsModule,
SharedModule,
DashboardSettingsModule
DashboardSettingsModule,
DashboardDeviceModule
]
})
export class DashboardModule
@@ -31,6 +31,6 @@ export class DashboardResolver implements Resolve<any>
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any>
{
return this._dashboardService.getData();
return this._dashboardService.getSummaryData();
}
}
@@ -44,7 +44,7 @@ export class DashboardService
/**
* Get data
*/
getData(): Observable<any>
getSummaryData(): Observable<any>
{
return this._httpClient.get(getBasePath() + '/api/summary').pipe(
tap((response: any) => {
@@ -52,4 +52,14 @@ export class DashboardService
})
);
}
getSummaryTempData(durationKey: string): Observable<any>
{
let params = {}
if(durationKey){
params["duration_key"] = durationKey
}
return this._httpClient.get(getBasePath() + '/api/summary/temp', {params: params});
}
}
@@ -1,33 +1,60 @@
import { Pipe, PipeTransform } from '@angular/core';
import {deviceDisplayTitle} from "app/layout/common/dashboard-device/dashboard-device.component";
@Pipe({
name: 'deviceSort'
})
export class DeviceSortPipe implements PipeTransform {
numericalStatus(device): number {
if(!device.smart_results[0]){
return 0
} else if (device.smart_results[0].smart_status == 'passed'){
return 1
} else {
return -1
statusCompareFn(a: any, b: any) {
function deviceStatus(deviceSummary): number {
if(!deviceSummary.smart){
return 0
} else if (deviceSummary.device.device_status == 0){
return 1
} else {
return deviceSummary.device.device_status * -1 // will return range from -1, -2, -3
}
}
let left = deviceStatus(a)
let right = deviceStatus(b)
return left - right;
}
titleCompareFn(dashboardDisplay: string) {
return function (a: any, b: any){
let _dashboardDisplay = dashboardDisplay
let left = deviceDisplayTitle(a.device, _dashboardDisplay) || deviceDisplayTitle(a.device, 'name')
let right = deviceDisplayTitle(b.device, _dashboardDisplay) || deviceDisplayTitle(b.device, 'name')
if( left < right )
return -1;
if( left > right )
return 1;
return 0;
}
}
transform(devices: Array<unknown>, ...args: unknown[]): Array<unknown> {
transform(deviceSummaries: Array<unknown>, sortBy = 'status', dashboardDisplay = "name"): Array<unknown> {
let compareFn = undefined
switch (sortBy) {
case 'status':
compareFn = this.statusCompareFn
break;
case 'title':
compareFn = this.titleCompareFn(dashboardDisplay)
break;
}
//failed, unknown/empty, passed
devices.sort((a: any, b: any) => {
deviceSummaries.sort(compareFn);
let left = this.numericalStatus(a)
let right = this.numericalStatus(b)
return left - right;
});
return devices;
return deviceSummaries;
}
}