Compare commits

...

45 Commits

Author SHA1 Message Date
packagrio-bot 5e33c33e75 (v0.7.3) Automated packaging of release by Packagr 2024-02-24 23:12:54 +00:00
Jason Kulatunga 3ea223fa8e Merge pull request #547 from kaysond/master
Add support for disabling repeat notifications if the values haven't changed
2024-02-24 15:10:29 -08:00
Jason Kulatunga 44275c66ca Merge pull request #569 from uhthomas/466
fix(collector): show correct nvme capacity
2024-02-24 09:32:59 -08:00
Jason Kulatunga 19bd59dc27 Merge pull request #577 from DrFrankensteinUK/patch-1
Update SUPPORTED_NAS_OS.md
2024-02-24 09:20:23 -08:00
DrFrankensteinUK b7fab3c94e Update SUPPORTED_NAS_OS.md
Added another link for the new Container Manager version of my guide for those on the newer DSM versions. The older guide while archived still functions correctly.
2024-02-17 16:47:05 +00:00
Aram Akhavan 09f4b34bf0 fix server test 2024-02-04 11:52:30 -08:00
Aram Akhavan f24abf254b Add tests for not repeating notifications 2024-02-04 11:38:52 -08:00
Aram Akhavan cc889f2a2d fix notify tests 2024-02-04 11:38:52 -08:00
Jason Kulatunga 2aa242e364 update mockgen instructions 2024-02-04 11:38:52 -08:00
Jason Kulatunga 1c193aa043 add database interface mock 2024-02-04 11:38:52 -08:00
Aram Akhavan 01c5a7fdfe Address review comments 2024-02-04 11:38:51 -08:00
Aram Akhavan 98d958888c refactor common code 2024-02-04 11:38:51 -08:00
Aram Akhavan 4e5c76b259 Add support for disabling repeat notifications
* Add a new database function for getting the tail

* Update ShouldNotify() to handle ignoring repeat notifications if set
2024-02-04 11:38:51 -08:00
Aram Akhavan 6417d71311 Add a setting for repeating notifications or not 2024-02-04 11:38:50 -08:00
Aram Akhavan 3285eb659f Fix some development issues 2024-02-04 11:38:50 -08:00
Thomas Way db86bac9ef fix(collector): show correct nvme capacity
Some nvme devices do not report their capacity through the usual
'user_capacity' field, instead the total capacity is reported with the
'nvme_total_capacity' field.

Fixes: #466
2024-01-23 22:02:02 +00:00
Jason Kulatunga a3dfce3561 Update INSTALL_HUB_SPOKE.md
fixes https://github.com/AnalogJ/scrutiny/issues/495
2024-01-23 12:57:22 -08:00
Jason Kulatunga 240178d742 Merge pull request #529 from KaeTuuN/patch-1
Update README.md Links to compose files
2024-01-23 12:19:39 -08:00
Jason Kulatunga 2dcb6cd6b6 Merge pull request #566 from PrplHaz4/patch-3
Add COLLECTOR_HOST_ID env var to hubspoke example
2024-01-23 12:16:27 -08:00
PrplHaz4 56df7b5797 Add COLLECTOR_HOST_ID env var to hubspoke example
Added COLLECTOR_HOST_ID environment variable to hubspoke example
2024-01-15 16:51:10 -05:00
Jason Kulatunga d54a0abc8c Merge pull request #559 from ibizaman/patch-1
Fix typo in readme
2023-12-26 14:50:02 -07:00
Pierre Penninckx 061f55f5b1 Fix typo in readme 2023-12-19 15:51:38 -08:00
Jason Kulatunga 5bbd4c3b64 update delete device message to clarify that no data will actually be effected, only scrutiny data.
fixes #544
2023-11-22 14:25:26 -08:00
Jason Kulatunga fb6c3d6a24 document Shoutrrr special characters in username & password -
fixes #532
2023-11-18 08:50:47 -08:00
KaeTuuN 87dc51a9c0 Update README.md Links to compose files
Links were not working, so I replaced them with working ones.
2023-10-18 10:33:37 +02:00
packagrio-bot c3a0fb7fb5 (v0.7.2) Automated packaging of release by Packagr 2023-10-17 13:19:35 +00:00
Jason Kulatunga 5e87608587 Merge pull request #520 from kaysond/patch-1 2023-10-17 06:09:52 -07:00
Jason Kulatunga ab7fd107e7 Merge pull request #527 from kaysond/master 2023-10-17 06:08:58 -07:00
Aram Akhavan 550cd59093 Fix parsing of attribute 188 on seagate drives 2023-10-14 21:39:12 -07:00
Aram Akhavan a8621d2bb0 Update permissions setting in Dockerfile.web
This fixes issues with assets loading when you run as non root users
2023-09-29 11:51:47 -07:00
Jason Kulatunga 4b1d9dc2d3 Merge pull request #519 from SlavikCA/patch-1 2023-09-27 10:17:26 -07:00
Slavik 22769b962e Docs: few more details about Traefik proxy 2023-09-26 21:11:59 -07:00
Slavik feb7909961 Docs: add Traefik REVERSE_PROXY config example 2023-09-26 21:04:26 -07:00
Jason Kulatunga 2f01e8c8e0 Merge pull request #512 from kaysond/master 2023-09-06 12:51:50 -07:00
Aram Akhavan 31c2daedf7 fix smart 188 thresholds 2023-09-03 10:37:43 -07:00
packagrio-bot ee893cc360 (v0.7.1) Automated packaging of release by Packagr 2023-04-08 23:01:22 +00:00
Jason Kulatunga d73907d357 Merge pull request #474 from AnalogJ/beta 2023-04-08 15:58:51 -07:00
Jason Kulatunga 0b50305f38 fix invalid COPY instruction. 2023-04-07 00:00:11 -07:00
Jason Kulatunga ee3d719c3a simplify docker image build
changes contributed by @modem7

fixes #461
2023-04-06 23:57:15 -07:00
Jason Kulatunga d76cdca4a5 Merge pull request #472 from adamantike/misc/add-support-for-yml-config-files 2023-04-06 17:46:03 -07:00
Jason Kulatunga b34ed607b7 Merge pull request #471 from adamantike/feat/add-support-for-ntfy-notifications 2023-04-06 17:42:50 -07:00
Michael Manganiello 932e191510 Allow configuration files with yml extension
If a `collector.yml` or `scrutiny.yml` configuration file is present,
use it as long as a `.yaml` version is not available too.

Fixes #79
2023-04-06 20:55:22 -03:00
Michael Manganiello 3a6c407fe7 Add support for ntfy notifications
Updates [`shoutrrr`](containrrr.dev/shoutrrr/) to `v0.7.1` to enable
support for [ntfy](https://ntfy.sh/) notifications.

Fixes #433.
2023-04-06 17:04:48 -03:00
Jason Kulatunga 8c3afc31f4 Merge pull request #449 from adamantike/fix/delete-influxdb-deb-from-docker-image 2023-04-05 23:25:52 -07:00
Michael Manganiello fb7848f341 Delete temporary deb file from omnibus Docker image
This considerably reduces the Docker image size for the `omnibus` variant:

```
scrutiny-omnibus-master         latest            cd7a7dde100b   9 minutes ago   538MB
scrutiny-omnibus-fix-applied    latest            5f7ac124ef50   6 minutes ago   431MB
```
2023-02-22 16:25:09 -03:00
45 changed files with 1875 additions and 450 deletions
-14
View File
@@ -74,13 +74,6 @@ jobs:
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: "Populate frontend version information" - name: "Populate frontend version information"
run: "cd webapp/frontend && ./git.version.sh" run: "cd webapp/frontend && ./git.version.sh"
- name: "Install Node"
uses: actions/setup-node@v3
with:
node-version: 16
- name: "Generate frontend"
run: |
make binary-frontend && echo "print contents of ./dist" && ls -alt ./dist
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
with: with:
@@ -132,13 +125,6 @@ jobs:
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: "Populate frontend version information" - name: "Populate frontend version information"
run: "cd webapp/frontend && ./git.version.sh" run: "cd webapp/frontend && ./git.version.sh"
- name: "Install Node"
uses: actions/setup-node@v3
with:
node-version: 16
- name: "Generate frontend"
run: |
make binary-frontend && echo "print contents of ./dist" && ls -alt ./dist
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
with: with:
-7
View File
@@ -19,13 +19,6 @@ jobs:
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: "Populate frontend version information" - name: "Populate frontend version information"
run: "cd webapp/frontend && ./git.version.sh" run: "cd webapp/frontend && ./git.version.sh"
- name: "Install Node"
uses: actions/setup-node@v3
with:
node-version: 16
- name: "Generate frontend"
run: |
make binary-frontend && echo "print contents of ./dist" && ls -alt ./dist
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
with: with:
+2
View File
@@ -66,3 +66,5 @@ scrutiny.yaml
coverage.txt coverage.txt
/config /config
/influxdb /influxdb
.angular
web.log
+1 -1
View File
@@ -100,7 +100,7 @@ binary-frontend: export NPM_CONFIG_LOGLEVEL = warn
binary-frontend: export NG_CLI_ANALYTICS = false binary-frontend: export NG_CLI_ANALYTICS = false
binary-frontend: binary-frontend:
cd webapp/frontend cd webapp/frontend
npm install -g @angular/cli@9.1.4 npm install -g @angular/cli@v13-lts
mkdir -p $(CURDIR)/dist mkdir -p $(CURDIR)/dist
npm ci npm ci
npm run build:prod -- --output-path=$(CURDIR)/dist npm run build:prod -- --output-path=$(CURDIR)/dist
+4 -3
View File
@@ -26,7 +26,7 @@ If you run a server with more than a couple of hard drives, you're probably alre
> smartd is a daemon that monitors the Self-Monitoring, Analysis and Reporting Technology (SMART) system built into many ATA, IDE and SCSI-3 hard drives. The purpose of SMART is to monitor the reliability of the hard drive and predict drive failures, and to carry out different types of drive self-tests. > smartd is a daemon that monitors the Self-Monitoring, Analysis and Reporting Technology (SMART) system built into many ATA, IDE and SCSI-3 hard drives. The purpose of SMART is to monitor the reliability of the hard drive and predict drive failures, and to carry out different types of drive self-tests.
Theses S.M.A.R.T hard drive self-tests can help you detect and replace failing hard drives before they cause permanent data loss. However, there's a couple issues with `smartd`: These S.M.A.R.T hard drive self-tests can help you detect and replace failing hard drives before they cause permanent data loss. However, there's a couple issues with `smartd`:
- There are more than a hundred S.M.A.R.T attributes, however `smartd` does not differentiate between critical and informational metrics - There are more than a hundred S.M.A.R.T attributes, however `smartd` does not differentiate between critical and informational metrics
- `smartd` does not record S.M.A.R.T attribute history, so it can be hard to determine if an attribute is degrading slowly over time. - `smartd` does not record S.M.A.R.T attribute history, so it can be hard to determine if an attribute is degrading slowly over time.
@@ -69,7 +69,7 @@ See [docs/TROUBLESHOOTING_DEVICE_COLLECTOR.md](./docs/TROUBLESHOOTING_DEVICE_COL
If you're using Docker, getting started is as simple as running the following command: If you're using Docker, getting started is as simple as running the following command:
> See [docker/example.omnibus.docker-compose.yml](./docker/example.omnibus.docker-compose.yml) for a docker-compose file. > See [docker/example.omnibus.docker-compose.yml](https://github.com/AnalogJ/scrutiny/blob/master/docker/example.omnibus.docker-compose.yml) for a docker-compose file.
```bash ```bash
docker run -it --rm -p 8080:8080 -p 8086:8086 \ docker run -it --rm -p 8080:8080 -p 8086:8086 \
@@ -100,7 +100,7 @@ other Docker images:
- `influxdb:2.2` - InfluxDB image, used by the Web container to persist SMART data. Only one container necessary - `influxdb:2.2` - InfluxDB image, used by the Web container to persist SMART data. Only one container necessary
See [docs/TROUBLESHOOTING_INFLUXDB.md](./docs/TROUBLESHOOTING_INFLUXDB.md) See [docs/TROUBLESHOOTING_INFLUXDB.md](./docs/TROUBLESHOOTING_INFLUXDB.md)
> See [docker/example.hubspoke.docker-compose.yml](./docker/example.hubspoke.docker-compose.yml) for a docker-compose file. > See [docker/example.hubspoke.docker-compose.yml](https://github.com/AnalogJ/scrutiny/blob/master/docker/example.hubspoke.docker-compose.yml) for a docker-compose file.
```bash ```bash
docker run --rm -p 8086:8086 \ docker run --rm -p 8086:8086 \
@@ -174,6 +174,7 @@ Scrutiny supports sending SMART device failure notifications via the following s
- IFTTT - IFTTT
- Join - Join
- Mattermost - Mattermost
- ntfy
- Pushbullet - Pushbullet
- Pushover - Pushover
- Slack - Slack
@@ -30,8 +30,14 @@ func main() {
os.Exit(1) os.Exit(1)
} }
configFilePath := "/opt/scrutiny/config/collector.yaml"
configFilePathAlternative := "/opt/scrutiny/config/collector.yml"
if !utils.FileExists(configFilePath) && utils.FileExists(configFilePathAlternative) {
configFilePath = configFilePathAlternative
}
//we're going to load the config file manually, since we need to validate it. //we're going to load the config file manually, since we need to validate it.
err = config.ReadConfig("/opt/scrutiny/config/collector.yaml") // Find and read the config file err = config.ReadConfig(configFilePath) // Find and read the config file
if _, ok := err.(errors.ConfigFileMissingError); ok { // Handle errors reading the config file if _, ok := err.(errors.ConfigFileMissingError); ok { // Handle errors reading the config file
//ignore "could not find config file" //ignore "could not find config file"
} else if err != nil { } else if err != nil {
+6 -4
View File
@@ -3,13 +3,14 @@ package detect
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"strings"
"github.com/analogj/scrutiny/collector/pkg/common/shell" "github.com/analogj/scrutiny/collector/pkg/common/shell"
"github.com/analogj/scrutiny/collector/pkg/config" "github.com/analogj/scrutiny/collector/pkg/config"
"github.com/analogj/scrutiny/collector/pkg/models" "github.com/analogj/scrutiny/collector/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector" "github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"os"
"strings"
) )
type Detect struct { type Detect struct {
@@ -47,7 +48,7 @@ func (d *Detect) SmartctlScan() ([]models.Device, error) {
return detectedDevices, nil return detectedDevices, nil
} }
//updates a device model with information from smartctl --scan // updates a device model with information from smartctl --scan
// It has a couple of issues however: // It has a couple of issues however:
// - WWN is provided as component data, rather than a "string". We'll have to generate the WWN value ourselves // - WWN is provided as component data, rather than a "string". We'll have to generate the WWN value ourselves
// - WWN from smartctl only provided for ATA protocol drives, NVMe and SCSI drives do not include WWN. // - WWN from smartctl only provided for ATA protocol drives, NVMe and SCSI drives do not include WWN.
@@ -81,8 +82,9 @@ func (d *Detect) SmartCtlInfo(device *models.Device) error {
device.SerialNumber = availableDeviceInfo.SerialNumber device.SerialNumber = availableDeviceInfo.SerialNumber
device.Firmware = availableDeviceInfo.FirmwareVersion device.Firmware = availableDeviceInfo.FirmwareVersion
device.RotationSpeed = availableDeviceInfo.RotationRate device.RotationSpeed = availableDeviceInfo.RotationRate
device.Capacity = availableDeviceInfo.UserCapacity.Bytes device.Capacity = availableDeviceInfo.Capacity()
device.FormFactor = availableDeviceInfo.FormFactor.Name device.FormFactor = availableDeviceInfo.FormFactor.Name
device.DeviceType = availableDeviceInfo.Device.Type
device.DeviceProtocol = availableDeviceInfo.Device.Protocol device.DeviceProtocol = availableDeviceInfo.Device.Protocol
if len(availableDeviceInfo.Vendor) > 0 { if len(availableDeviceInfo.Vendor) > 0 {
device.Manufacturer = availableDeviceInfo.Vendor device.Manufacturer = availableDeviceInfo.Vendor
+99 -36
View File
@@ -1,19 +1,22 @@
package detect_test package detect_test
import ( import (
"os"
"strings"
"testing"
mock_shell "github.com/analogj/scrutiny/collector/pkg/common/shell/mock" mock_shell "github.com/analogj/scrutiny/collector/pkg/common/shell/mock"
mock_config "github.com/analogj/scrutiny/collector/pkg/config/mock" mock_config "github.com/analogj/scrutiny/collector/pkg/config/mock"
"github.com/analogj/scrutiny/collector/pkg/detect" "github.com/analogj/scrutiny/collector/pkg/detect"
"github.com/analogj/scrutiny/collector/pkg/models" "github.com/analogj/scrutiny/collector/pkg/models"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"io/ioutil"
"testing"
) )
func TestDetect_SmartctlScan(t *testing.T) { func TestDetect_SmartctlScan(t *testing.T) {
//setup // setup
mockCtrl := gomock.NewController(t) mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) fakeConfig := mock_config.NewMockInterface(mockCtrl)
@@ -23,7 +26,7 @@ func TestDetect_SmartctlScan(t *testing.T) {
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json") fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeShell := mock_shell.NewMockInterface(mockCtrl) fakeShell := mock_shell.NewMockInterface(mockCtrl)
testScanResults, err := ioutil.ReadFile("testdata/smartctl_scan_simple.json") testScanResults, err := os.ReadFile("testdata/smartctl_scan_simple.json")
fakeShell.EXPECT().Command(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(string(testScanResults), err) fakeShell.EXPECT().Command(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(string(testScanResults), err)
d := detect.Detect{ d := detect.Detect{
@@ -32,17 +35,17 @@ func TestDetect_SmartctlScan(t *testing.T) {
Config: fakeConfig, Config: fakeConfig,
} }
//test // test
scannedDevices, err := d.SmartctlScan() scannedDevices, err := d.SmartctlScan()
//assert // assert
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 7, len(scannedDevices)) require.Equal(t, 7, len(scannedDevices))
require.Equal(t, "scsi", scannedDevices[0].DeviceType) require.Equal(t, "scsi", scannedDevices[0].DeviceType)
} }
func TestDetect_SmartctlScan_Megaraid(t *testing.T) { func TestDetect_SmartctlScan_Megaraid(t *testing.T) {
//setup // setup
mockCtrl := gomock.NewController(t) mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) fakeConfig := mock_config.NewMockInterface(mockCtrl)
@@ -52,7 +55,7 @@ func TestDetect_SmartctlScan_Megaraid(t *testing.T) {
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json") fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeShell := mock_shell.NewMockInterface(mockCtrl) fakeShell := mock_shell.NewMockInterface(mockCtrl)
testScanResults, err := ioutil.ReadFile("testdata/smartctl_scan_megaraid.json") testScanResults, err := os.ReadFile("testdata/smartctl_scan_megaraid.json")
fakeShell.EXPECT().Command(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(string(testScanResults), err) fakeShell.EXPECT().Command(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(string(testScanResults), err)
d := detect.Detect{ d := detect.Detect{
@@ -61,20 +64,20 @@ func TestDetect_SmartctlScan_Megaraid(t *testing.T) {
Config: fakeConfig, Config: fakeConfig,
} }
//test // test
scannedDevices, err := d.SmartctlScan() scannedDevices, err := d.SmartctlScan()
//assert // assert
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 2, len(scannedDevices)) require.Equal(t, 2, len(scannedDevices))
require.Equal(t, []models.Device{ require.Equal(t, []models.Device{
models.Device{DeviceName: "bus/0", DeviceType: "megaraid,0"}, {DeviceName: "bus/0", DeviceType: "megaraid,0"},
models.Device{DeviceName: "bus/0", DeviceType: "megaraid,1"}, {DeviceName: "bus/0", DeviceType: "megaraid,1"},
}, scannedDevices) }, scannedDevices)
} }
func TestDetect_SmartctlScan_Nvme(t *testing.T) { func TestDetect_SmartctlScan_Nvme(t *testing.T) {
//setup // setup
mockCtrl := gomock.NewController(t) mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) fakeConfig := mock_config.NewMockInterface(mockCtrl)
@@ -84,7 +87,7 @@ func TestDetect_SmartctlScan_Nvme(t *testing.T) {
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json") fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeShell := mock_shell.NewMockInterface(mockCtrl) fakeShell := mock_shell.NewMockInterface(mockCtrl)
testScanResults, err := ioutil.ReadFile("testdata/smartctl_scan_nvme.json") testScanResults, err := os.ReadFile("testdata/smartctl_scan_nvme.json")
fakeShell.EXPECT().Command(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(string(testScanResults), err) fakeShell.EXPECT().Command(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(string(testScanResults), err)
d := detect.Detect{ d := detect.Detect{
@@ -93,19 +96,19 @@ func TestDetect_SmartctlScan_Nvme(t *testing.T) {
Config: fakeConfig, Config: fakeConfig,
} }
//test // test
scannedDevices, err := d.SmartctlScan() scannedDevices, err := d.SmartctlScan()
//assert // assert
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 1, len(scannedDevices)) require.Equal(t, 1, len(scannedDevices))
require.Equal(t, []models.Device{ require.Equal(t, []models.Device{
models.Device{DeviceName: "nvme0", DeviceType: "nvme"}, {DeviceName: "nvme0", DeviceType: "nvme"},
}, scannedDevices) }, scannedDevices)
} }
func TestDetect_TransformDetectedDevices_Empty(t *testing.T) { func TestDetect_TransformDetectedDevices_Empty(t *testing.T) {
//setup // setup
mockCtrl := gomock.NewController(t) mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) fakeConfig := mock_config.NewMockInterface(mockCtrl)
@@ -129,16 +132,16 @@ func TestDetect_TransformDetectedDevices_Empty(t *testing.T) {
Config: fakeConfig, Config: fakeConfig,
} }
//test // test
transformedDevices := d.TransformDetectedDevices(detectedDevices) transformedDevices := d.TransformDetectedDevices(detectedDevices)
//assert // assert
require.Equal(t, "sda", transformedDevices[0].DeviceName) require.Equal(t, "sda", transformedDevices[0].DeviceName)
require.Equal(t, "scsi", transformedDevices[0].DeviceType) require.Equal(t, "scsi", transformedDevices[0].DeviceType)
} }
func TestDetect_TransformDetectedDevices_Ignore(t *testing.T) { func TestDetect_TransformDetectedDevices_Ignore(t *testing.T) {
//setup // setup
mockCtrl := gomock.NewController(t) mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) fakeConfig := mock_config.NewMockInterface(mockCtrl)
@@ -162,15 +165,15 @@ func TestDetect_TransformDetectedDevices_Ignore(t *testing.T) {
Config: fakeConfig, Config: fakeConfig,
} }
//test // test
transformedDevices := d.TransformDetectedDevices(detectedDevices) transformedDevices := d.TransformDetectedDevices(detectedDevices)
//assert // assert
require.Equal(t, []models.Device{}, transformedDevices) require.Equal(t, []models.Device{}, transformedDevices)
} }
func TestDetect_TransformDetectedDevices_Raid(t *testing.T) { func TestDetect_TransformDetectedDevices_Raid(t *testing.T) {
//setup // setup
mockCtrl := gomock.NewController(t) mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) fakeConfig := mock_config.NewMockInterface(mockCtrl)
@@ -187,7 +190,8 @@ func TestDetect_TransformDetectedDevices_Raid(t *testing.T) {
Device: "/dev/twa0", Device: "/dev/twa0",
DeviceType: []string{"3ware,0", "3ware,1", "3ware,2", "3ware,3", "3ware,4", "3ware,5"}, DeviceType: []string{"3ware,0", "3ware,1", "3ware,2", "3ware,3", "3ware,4", "3ware,5"},
Ignore: false, Ignore: false,
}}) },
})
detectedDevices := models.Scan{ detectedDevices := models.Scan{
Devices: []models.ScanDevice{ Devices: []models.ScanDevice{
{ {
@@ -203,15 +207,15 @@ func TestDetect_TransformDetectedDevices_Raid(t *testing.T) {
Config: fakeConfig, Config: fakeConfig,
} }
//test // test
transformedDevices := d.TransformDetectedDevices(detectedDevices) transformedDevices := d.TransformDetectedDevices(detectedDevices)
//assert // assert
require.Equal(t, 12, len(transformedDevices)) require.Equal(t, 12, len(transformedDevices))
} }
func TestDetect_TransformDetectedDevices_Simple(t *testing.T) { func TestDetect_TransformDetectedDevices_Simple(t *testing.T) {
//setup // setup
mockCtrl := gomock.NewController(t) mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) fakeConfig := mock_config.NewMockInterface(mockCtrl)
@@ -234,17 +238,17 @@ func TestDetect_TransformDetectedDevices_Simple(t *testing.T) {
Config: fakeConfig, Config: fakeConfig,
} }
//test // test
transformedDevices := d.TransformDetectedDevices(detectedDevices) transformedDevices := d.TransformDetectedDevices(detectedDevices)
//assert // assert
require.Equal(t, 1, len(transformedDevices)) require.Equal(t, 1, len(transformedDevices))
require.Equal(t, "sat+megaraid", transformedDevices[0].DeviceType) require.Equal(t, "sat+megaraid", transformedDevices[0].DeviceType)
} }
// test https://github.com/AnalogJ/scrutiny/issues/255#issuecomment-1164024126 // test https://github.com/AnalogJ/scrutiny/issues/255#issuecomment-1164024126
func TestDetect_TransformDetectedDevices_WithoutDeviceTypeOverride(t *testing.T) { func TestDetect_TransformDetectedDevices_WithoutDeviceTypeOverride(t *testing.T) {
//setup // setup
mockCtrl := gomock.NewController(t) mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) fakeConfig := mock_config.NewMockInterface(mockCtrl)
@@ -267,16 +271,16 @@ func TestDetect_TransformDetectedDevices_WithoutDeviceTypeOverride(t *testing.T)
Config: fakeConfig, Config: fakeConfig,
} }
//test // test
transformedDevices := d.TransformDetectedDevices(detectedDevices) transformedDevices := d.TransformDetectedDevices(detectedDevices)
//assert // assert
require.Equal(t, 1, len(transformedDevices)) require.Equal(t, 1, len(transformedDevices))
require.Equal(t, "scsi", transformedDevices[0].DeviceType) require.Equal(t, "scsi", transformedDevices[0].DeviceType)
} }
func TestDetect_TransformDetectedDevices_WhenDeviceNotDetected(t *testing.T) { func TestDetect_TransformDetectedDevices_WhenDeviceNotDetected(t *testing.T) {
//setup // setup
mockCtrl := gomock.NewController(t) mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish() defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl) fakeConfig := mock_config.NewMockInterface(mockCtrl)
@@ -290,10 +294,69 @@ func TestDetect_TransformDetectedDevices_WhenDeviceNotDetected(t *testing.T) {
Config: fakeConfig, Config: fakeConfig,
} }
//test // test
transformedDevices := d.TransformDetectedDevices(detectedDevices) transformedDevices := d.TransformDetectedDevices(detectedDevices)
//assert // assert
require.Equal(t, 1, len(transformedDevices)) require.Equal(t, 1, len(transformedDevices))
require.Equal(t, "ata", transformedDevices[0].DeviceType) require.Equal(t, "ata", transformedDevices[0].DeviceType)
} }
func TestDetect_SmartCtlInfo(t *testing.T) {
t.Run("should report nvme info", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
const (
someArgs = "--info --json"
// device info
someDeviceName = "some-device-name"
someModelName = "KCD61LUL3T84"
someSerialNumber = "61Q0A05UT7B8"
someFirmware = "8002"
someDeviceProtocol = "NVMe"
someDeviceType = "nvme"
someCapacity int64 = 3840755982336
)
fakeConfig := mock_config.NewMockInterface(ctrl)
fakeConfig.EXPECT().
GetCommandMetricsInfoArgs("/dev/" + someDeviceName).
Return(someArgs)
fakeConfig.EXPECT().
GetString("commands.metrics_smartctl_bin").
Return("smartctl")
someLogger := logrus.WithFields(logrus.Fields{})
smartctlInfoResults, err := os.ReadFile("testdata/smartctl_info_nvme.json")
require.NoError(t, err)
fakeShell := mock_shell.NewMockInterface(ctrl)
fakeShell.EXPECT().
Command(someLogger, "smartctl", append(strings.Split(someArgs, " "), "/dev/"+someDeviceName), "", gomock.Any()).
Return(string(smartctlInfoResults), err)
d := detect.Detect{
Logger: someLogger,
Shell: fakeShell,
Config: fakeConfig,
}
someDevice := &models.Device{
WWN: "some wwn",
DeviceName: someDeviceName,
}
require.NoError(t, d.SmartCtlInfo(someDevice))
assert.Equal(t, someDeviceName, someDevice.DeviceName)
assert.Equal(t, someModelName, someDevice.ModelName)
assert.Equal(t, someSerialNumber, someDevice.SerialNumber)
assert.Equal(t, someFirmware, someDevice.Firmware)
assert.Equal(t, someDeviceProtocol, someDevice.DeviceProtocol)
assert.Equal(t, someDeviceType, someDevice.DeviceType)
assert.Equal(t, someCapacity, someDevice.Capacity)
})
}
+48
View File
@@ -0,0 +1,48 @@
{
"json_format_version": [
1,
0
],
"smartctl": {
"version": [
7,
2
],
"svn_revision": "5155",
"platform_info": "x86_64-linux-6.1.69-talos",
"build_info": "(local build)",
"argv": [
"smartctl",
"--info",
"--json",
"/dev/nvme4"
],
"exit_status": 0
},
"device": {
"name": "/dev/nvme4",
"info_name": "/dev/nvme4",
"type": "nvme",
"protocol": "NVMe"
},
"model_name": "KCD61LUL3T84",
"serial_number": "61Q0A05UT7B8",
"firmware_version": "8002",
"nvme_pci_vendor": {
"id": 7695,
"subsystem_id": 7695
},
"nvme_ieee_oui_identifier": 9233294,
"nvme_total_capacity": 3840755982336,
"nvme_unallocated_capacity": 0,
"nvme_controller_id": 1,
"nvme_version": {
"string": "1.4",
"value": 66560
},
"nvme_number_of_namespaces": 16,
"local_time": {
"time_t": 1706045146,
"asctime": "Tue Jan 23 21:25:46 2024 UTC"
}
}
+28 -16
View File
@@ -1,50 +1,62 @@
# syntax=docker/dockerfile:1.4
######################################################################################################################## ########################################################################################################################
# Omnibus Image # Omnibus Image
# NOTE: this image requires the `make binary-frontend` target to have been run before `docker build` The `dist` directory must exist.
######################################################################################################################## ########################################################################################################################
######## Build the frontend
FROM --platform=${BUILDPLATFORM} node AS frontendbuild
WORKDIR /go/src/github.com/analogj/scrutiny
COPY --link . /go/src/github.com/analogj/scrutiny
######## RUN make binary-frontend
######## Build the backend
FROM golang:1.20-bullseye as backendbuild FROM golang:1.20-bullseye as backendbuild
WORKDIR /go/src/github.com/analogj/scrutiny WORKDIR /go/src/github.com/analogj/scrutiny
COPY . /go/src/github.com/analogj/scrutiny COPY --link . /go/src/github.com/analogj/scrutiny
RUN make binary-clean binary-all WEB_BINARY_NAME=scrutiny RUN make binary-clean binary-all WEB_BINARY_NAME=scrutiny
######## ######## Combine build artifacts in runtime image
FROM debian:bullseye-slim as runtime FROM debian:bullseye-slim as runtime
ARG TARGETARCH ARG TARGETARCH
EXPOSE 8080 EXPOSE 8080
WORKDIR /opt/scrutiny WORKDIR /opt/scrutiny
ENV PATH="/opt/scrutiny/bin:${PATH}" ENV PATH="/opt/scrutiny/bin:${PATH}"
ENV INFLUXD_CONFIG_PATH=/opt/scrutiny/influxdb ENV INFLUXD_CONFIG_PATH=/opt/scrutiny/influxdb
ENV S6VER="1.21.8.0"
ENV INFLUXVER="2.2.0"
RUN apt-get update && apt-get install -y cron smartmontools ca-certificates curl tzdata \ RUN apt-get update && DEBIAN_FRONTEND=noninteractive \
apt-get install -y --no-install-recommends \
ca-certificates \
cron \
curl \
smartmontools \
tzdata \
&& update-ca-certificates \ && update-ca-certificates \
&& case ${TARGETARCH} in \ && case ${TARGETARCH} in \
"amd64") S6_ARCH=amd64 ;; \ "amd64") S6_ARCH=amd64 ;; \
"arm64") S6_ARCH=aarch64 ;; \ "arm64") S6_ARCH=aarch64 ;; \
esac \ esac \
&& curl https://github.com/just-containers/s6-overlay/releases/download/v1.21.8.0/s6-overlay-${S6_ARCH}.tar.gz -L -s --output /tmp/s6-overlay-${S6_ARCH}.tar.gz \ && curl https://github.com/just-containers/s6-overlay/releases/download/v${S6VER}/s6-overlay-${S6_ARCH}.tar.gz -L -s --output /tmp/s6-overlay-${S6_ARCH}.tar.gz \
&& tar xzf /tmp/s6-overlay-${S6_ARCH}.tar.gz -C / \ && tar xzf /tmp/s6-overlay-${S6_ARCH}.tar.gz -C / \
&& rm -rf /tmp/s6-overlay-${S6_ARCH}.tar.gz \ && rm -rf /tmp/s6-overlay-${S6_ARCH}.tar.gz \
&& curl -L https://dl.influxdata.com/influxdb/releases/influxdb2-2.2.0-${TARGETARCH}.deb --output /tmp/influxdb2-2.2.0-${TARGETARCH}.deb \ && curl -L https://dl.influxdata.com/influxdb/releases/influxdb2-${INFLUXVER}-${TARGETARCH}.deb --output /tmp/influxdb2-${INFLUXVER}-${TARGETARCH}.deb \
&& dpkg -i --force-all /tmp/influxdb2-2.2.0-${TARGETARCH}.deb && dpkg -i --force-all /tmp/influxdb2-${INFLUXVER}-${TARGETARCH}.deb \
&& rm -rf /tmp/influxdb2-2.2.0-${TARGETARCH}.deb
COPY /rootfs / COPY /rootfs /
COPY /rootfs/etc/cron.d/scrutiny /etc/cron.d/scrutiny COPY --link --from=backendbuild --chmod=755 /go/src/github.com/analogj/scrutiny/scrutiny /opt/scrutiny/bin/
COPY --from=backendbuild /go/src/github.com/analogj/scrutiny/scrutiny /opt/scrutiny/bin/ COPY --link --from=backendbuild --chmod=755 /go/src/github.com/analogj/scrutiny/scrutiny-collector-metrics /opt/scrutiny/bin/
COPY --from=backendbuild /go/src/github.com/analogj/scrutiny/scrutiny-collector-metrics /opt/scrutiny/bin/ COPY --link --from=frontendbuild --chmod=644 /go/src/github.com/analogj/scrutiny/dist /opt/scrutiny/web
COPY dist /opt/scrutiny/web RUN chmod 0644 /etc/cron.d/scrutiny && \
RUN chmod +x /opt/scrutiny/bin/scrutiny && \
chmod +x /opt/scrutiny/bin/scrutiny-collector-metrics && \
chmod 0644 /etc/cron.d/scrutiny && \
rm -f /etc/cron.daily/* && \ rm -f /etc/cron.daily/* && \
mkdir -p /opt/scrutiny/web && \ mkdir -p /opt/scrutiny/web && \
mkdir -p /opt/scrutiny/config && \ mkdir -p /opt/scrutiny/config && \
chmod -R ugo+rwx /opt/scrutiny/config chmod -R ugo+rwx /opt/scrutiny/config
CMD ["/init"] CMD ["/init"]
+15 -10
View File
@@ -1,20 +1,25 @@
# syntax=docker/dockerfile:1.4
######################################################################################################################## ########################################################################################################################
# Web Image # Web Image
# NOTE: this image requires the `make binary-frontend` target to have been run before `docker build` The `dist` directory must exist.
######################################################################################################################## ########################################################################################################################
######## Build the frontend
FROM --platform=${BUILDPLATFORM} node AS frontendbuild
WORKDIR /go/src/github.com/analogj/scrutiny
COPY --link . /go/src/github.com/analogj/scrutiny
######## RUN make binary-frontend
######## Build the backend
FROM golang:1.20-bullseye as backendbuild FROM golang:1.20-bullseye as backendbuild
WORKDIR /go/src/github.com/analogj/scrutiny WORKDIR /go/src/github.com/analogj/scrutiny
COPY --link . /go/src/github.com/analogj/scrutiny
COPY . /go/src/github.com/analogj/scrutiny
RUN make binary-clean binary-all WEB_BINARY_NAME=scrutiny RUN make binary-clean binary-all WEB_BINARY_NAME=scrutiny
######## ######## Combine build artifacts in runtime image
FROM debian:bullseye-slim as runtime FROM debian:bullseye-slim as runtime
EXPOSE 8080 EXPOSE 8080
WORKDIR /opt/scrutiny WORKDIR /opt/scrutiny
@@ -22,10 +27,10 @@ ENV PATH="/opt/scrutiny/bin:${PATH}"
RUN apt-get update && apt-get install -y ca-certificates curl tzdata && update-ca-certificates RUN apt-get update && apt-get install -y ca-certificates curl tzdata && update-ca-certificates
COPY --from=backendbuild /go/src/github.com/analogj/scrutiny/scrutiny /opt/scrutiny/bin/ COPY --link --from=backendbuild --chmod=755 /go/src/github.com/analogj/scrutiny/scrutiny /opt/scrutiny/bin/
COPY dist /opt/scrutiny/web COPY --link --from=frontendbuild --chmod=644 /go/src/github.com/analogj/scrutiny/dist /opt/scrutiny/web
RUN chmod +x /opt/scrutiny/bin/scrutiny && \ RUN mkdir -p /opt/scrutiny/web && \
mkdir -p /opt/scrutiny/web && \
mkdir -p /opt/scrutiny/config && \ mkdir -p /opt/scrutiny/config && \
chmod -R ugo+rwx /opt/scrutiny/config chmod -R a+rX /opt/scrutiny && \
chmod -R a+w /opt/scrutiny/config
CMD ["/opt/scrutiny/bin/scrutiny", "start"] CMD ["/opt/scrutiny/bin/scrutiny", "start"]
@@ -40,6 +40,7 @@ services:
- '/run/udev:/run/udev:ro' - '/run/udev:/run/udev:ro'
environment: environment:
COLLECTOR_API_ENDPOINT: 'http://web:8080' COLLECTOR_API_ENDPOINT: 'http://web:8080'
COLLECTOR_HOST_ID: 'scrutiny-collector-hostname'
depends_on: depends_on:
web: web:
condition: service_healthy condition: service_healthy
+1 -1
View File
@@ -91,7 +91,7 @@ services:
- SCRUTINY_WEB_INFLUXDB_ORG=homelab - SCRUTINY_WEB_INFLUXDB_ORG=homelab
- SCRUTINY_WEB_INFLUXDB_BUCKET=scrutiny - SCRUTINY_WEB_INFLUXDB_BUCKET=scrutiny
# Optional but highly recommended to notify you in case of a problem # Optional but highly recommended to notify you in case of a problem
- SCRUTINY_WEB_NOTIFY_URLS=["http://gotify:80/message?token=a-gotify-token"] - SCRUTINY_NOTIFY_URLS=["http://gotify:80/message?token=a-gotify-token"]
depends_on: depends_on:
- influxdb - influxdb
restart: unless-stopped restart: unless-stopped
+2 -1
View File
@@ -9,7 +9,8 @@ in `docs/guides/` or elsewhere) it will be linked here.
- [ ] Proxmox - [ ] Proxmox
- [x] Synology - [x] Synology
- [Hub/Spoke Deployment - Collector](./INSTALL_SYNOLOGY_COLLECTOR.md) - [Hub/Spoke Deployment - Collector](./INSTALL_SYNOLOGY_COLLECTOR.md)
- [Omnibus Deployment](https://drfrankenstein.co.uk/2022/07/28/scrutiny-in-docker-on-a-synology-nas) - [Omnibus Deployment](https://drfrankenstein.co.uk/2022/07/28/scrutiny-in-docker-on-a-synology-nas) - Docker Package
- [Omnibus Deployment](https://drfrankenstein.co.uk/scrutiny-in-container-manager-on-a-synology-nas/) - Container Manager Package
- [ ] OMV - [ ] OMV
- [ ] Amahi - [ ] Amahi
- [ ] Running in a LXC container - [ ] Running in a LXC container
+16 -1
View File
@@ -24,8 +24,23 @@ SCRUTINY_MESSAGE - eg. "Scrutiny SMART error notification for device: %s\nFailur
SCRUTINY_HOST_ID - (optional) eg. "my-custom-host-id" SCRUTINY_HOST_ID - (optional) eg. "my-custom-host-id"
``` ```
# Special Characters
`Shoutrrr` supports special characters in the username and password fields, however you'll need to url-encode the
username and the password separately.
- if your username is: `myname@example.com`
- if your password is `124@34$1`
Then your `shoutrrr` url will look something like:
- `smtp://myname%40example%2Ecom:124%4034%241@ms.my.domain.com:587`
# Testing Notifications # Testing Notifications
You can test that your notifications are configured correctly by posting an empty payload to the notifications health check API.
You can test that your notifications are configured correctly by posting an empty payload to the notifications health
check API.
``` ```
curl -X POST http://localhost:8080/api/health/notify curl -X POST http://localhost:8080/api/health/notify
``` ```
+33
View File
@@ -104,3 +104,36 @@ You may also configure these values using the following environmental variables
``` ```
3. run `docker-compose up` 3. run `docker-compose up`
4. visit [http://localhost:9090/custom/web](http://localhost:9090/custom/web) - access the scrutiny container via caddy reverse proxy 4. visit [http://localhost:9090/custom/web](http://localhost:9090/custom/web) - access the scrutiny container via caddy reverse proxy
## Traefik
Assuming, that you have Traefik up and running with [AutoDiscovery Using Traefik For Docker ](https://doc.traefik.io/traefik/providers/docker/),
here is an example of a `docker-compose.yml` file, with labels to enable Traefik reverse proxy and basic auth
```yaml
version: '3.5'
services:
scrutiny:
container_name: scrutiny
image: ghcr.io/analogj/scrutiny:master-omnibus
cap_add:
- SYS_RAWIO
- SYS_ADMIN
volumes:
- /run/udev:/run/udev:ro
- ./config:/opt/scrutiny/config
- ./influxdb:/opt/scrutiny/influxdb
labels:
- traefik.enable=true
- traefik.http.routers.scrutiny.rule=Host(`example.com`)
- traefik.http.services.scrutiny.loadbalancer.server.port=8080
# 2 labels below are optional, in case you want basic auth in Traefik:
- traefik.http.routers.scrutiny.middlewares=auth
- "traefik.http.middlewares.auth.basicauth.users=user:$$2y$$05$$G11Wm/dlWpXHENK..m8se.zxvaE8USJBp1Ws56sSCrOcwWDjsYHni"
# Note: when used in docker-compose.yml all dollar signs in the hash need to be doubled for escaping.
# To create user:password pair, it's possible to use this command:
# echo $(htpasswd -nB user) | sed -e s/\\$/\\$\\$/g
devices:
- "/dev/sda"
- "/dev/sdb"
- "/dev/nvme0"
```
+5
View File
@@ -60,6 +60,10 @@ log:
# Notification "urls" look like the following. For more information about service specific configuration see # Notification "urls" look like the following. For more information about service specific configuration see
# Shoutrrr's documentation: https://containrrr.dev/shoutrrr/services/overview/ # Shoutrrr's documentation: https://containrrr.dev/shoutrrr/services/overview/
#
# note, usernames and passwords containing special characters will need to be urlencoded.
# if your username is: "myname@example.com" and your password is "124@34$1"
# your shoutrrr url will look like: "smtp://myname%40example%2Ecom:124%4034%241@ms.my.domain.com:587"
#notify: #notify:
# urls: # urls:
@@ -73,6 +77,7 @@ log:
# - "pushbullet://api-token[/device/#channel/email]" # - "pushbullet://api-token[/device/#channel/email]"
# - "ifttt://key/?events=event1[,event2,...]&value1=value1&value2=value2&value3=value3" # - "ifttt://key/?events=event1[,event2,...]&value1=value1&value2=value2&value3=value3"
# - "mattermost://[username@]mattermost-host/token[/channel]" # - "mattermost://[username@]mattermost-host/token[/channel]"
# - "ntfy://username:password@host:port/topic"
# - "hangouts://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz" # - "hangouts://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz"
# - "zulip://bot-mail:bot-key@zulip-domain/?stream=name-or-id&topic=name" # - "zulip://bot-mail:bot-key@zulip-domain/?stream=name-or-id&topic=name"
# - "join://shoutrrr:api-key@join/?devices=device1[,device2, ...][&icon=icon][&title=title]" # - "join://shoutrrr:api-key@join/?devices=device1[,device2, ...][&icon=icon][&title=title]"
+30 -29
View File
@@ -4,30 +4,30 @@ go 1.20
require ( require (
github.com/analogj/go-util v0.0.0-20190301173314-5295e364eb14 github.com/analogj/go-util v0.0.0-20190301173314-5295e364eb14
github.com/containrrr/shoutrrr v0.6.1 github.com/containrrr/shoutrrr v0.7.1
github.com/fatih/color v1.10.0 github.com/fatih/color v1.15.0
github.com/gin-gonic/gin v1.6.3 github.com/gin-gonic/gin v1.6.3
github.com/glebarez/sqlite v1.4.5 github.com/glebarez/sqlite v1.4.5
github.com/go-gormigrate/gormigrate/v2 v2.0.0 github.com/go-gormigrate/gormigrate/v2 v2.0.0
github.com/golang/mock v1.4.3 github.com/golang/mock v1.6.0
github.com/influxdata/influxdb-client-go/v2 v2.9.0 github.com/influxdata/influxdb-client-go/v2 v2.9.0
github.com/jaypipes/ghw v0.6.1 github.com/jaypipes/ghw v0.6.1
github.com/mitchellh/mapstructure v1.2.2 github.com/mitchellh/mapstructure v1.5.0
github.com/samber/lo v1.25.0 github.com/samber/lo v1.25.0
github.com/sirupsen/logrus v1.4.2 github.com/sirupsen/logrus v1.6.0
github.com/spf13/viper v1.7.0 github.com/spf13/viper v1.14.0
github.com/stretchr/testify v1.7.1 github.com/stretchr/testify v1.8.1
github.com/urfave/cli/v2 v2.2.0 github.com/urfave/cli/v2 v2.2.0
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 golang.org/x/sync v0.1.0
gorm.io/gorm v1.23.5 gorm.io/gorm v1.23.5
) )
require ( require (
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deepmap/oapi-codegen v1.8.2 // indirect github.com/deepmap/oapi-codegen v1.8.2 // indirect
github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.17.2 // indirect github.com/glebarez/go-sqlite v1.17.2 // indirect
@@ -35,44 +35,45 @@ require (
github.com/go-playground/locales v0.13.0 // indirect github.com/go-playground/locales v0.13.0 // indirect
github.com/go-playground/universal-translator v0.17.0 // indirect github.com/go-playground/universal-translator v0.17.0 // indirect
github.com/go-playground/validator/v10 v10.2.0 // indirect github.com/go-playground/validator/v10 v10.2.0 // indirect
github.com/golang/protobuf v1.4.2 // indirect github.com/golang/protobuf v1.5.2 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect
github.com/jaypipes/pcidb v0.5.0 // indirect github.com/jaypipes/pcidb v0.5.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.4 // indirect github.com/jinzhu/now v1.1.4 // indirect
github.com/json-iterator/go v1.1.9 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect
github.com/kvz/logstreamer v0.0.0-20201023134116-02d20f4338f5 // indirect github.com/kvz/logstreamer v0.0.0-20201023134116-02d20f4338f5 // indirect
github.com/leodido/go-urn v1.2.0 // indirect github.com/leodido/go-urn v1.2.0 // indirect
github.com/magiconair/properties v1.8.1 // indirect github.com/magiconair/properties v1.8.6 // indirect
github.com/mattn/go-colorable v0.1.8 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml v1.7.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/afero v1.2.2 // indirect github.com/spf13/afero v1.9.2 // indirect
github.com/spf13/cast v1.3.1 // indirect github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.2.0 // indirect github.com/subosito/gotenv v1.4.1 // indirect
github.com/ugorji/go/codec v1.1.7 // indirect github.com/ugorji/go/codec v1.1.7 // indirect
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect golang.org/x/crypto v0.1.0 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect golang.org/x/net v0.1.0 // indirect
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 // indirect golang.org/x/sys v0.7.0 // indirect
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect golang.org/x/term v0.1.0 // indirect
golang.org/x/text v0.3.5 // indirect golang.org/x/text v0.4.0 // indirect
google.golang.org/protobuf v1.23.0 // indirect google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/ini.v1 v1.55.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect
modernc.org/libc v1.16.8 // indirect modernc.org/libc v1.16.8 // indirect
modernc.org/mathutil v1.4.1 // indirect modernc.org/mathutil v1.4.1 // indirect
+864 -122
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -29,8 +29,14 @@ func main() {
os.Exit(1) os.Exit(1)
} }
configFilePath := "/opt/scrutiny/config/scrutiny.yaml"
configFilePathAlternative := "/opt/scrutiny/config/scrutiny.yml"
if !utils.FileExists(configFilePath) && utils.FileExists(configFilePathAlternative) {
configFilePath = configFilePathAlternative
}
//we're going to load the config file manually, since we need to validate it. //we're going to load the config file manually, since we need to validate it.
err = config.ReadConfig("/opt/scrutiny/config/scrutiny.yaml") // Find and read the config file err = config.ReadConfig(configFilePath) // Find and read the config file
if _, ok := err.(errors.ConfigFileMissingError); ok { // Handle errors reading the config file if _, ok := err.(errors.ConfigFileMissingError); ok { // Handle errors reading the config file
//ignore "could not find config file" //ignore "could not find config file"
} else if err != nil { } else if err != nil {
-12
View File
@@ -1,12 +0,0 @@
package database
import (
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"sort"
)
func sortSmartMeasurementsDesc(smartResults []measurements.Smart) {
sort.SliceStable(smartResults, func(i, j int) bool {
return smartResults[i].Date.After(smartResults[j].Date)
})
}
@@ -1,30 +0,0 @@
package database
import (
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"github.com/stretchr/testify/require"
"testing"
"time"
)
func Test_sortSmartMeasurementsDesc_LatestFirst(t *testing.T) {
//setup
timeNow := time.Now()
smartResults := []measurements.Smart{
{
Date: timeNow.AddDate(0, 0, -2),
},
{
Date: timeNow,
},
{
Date: timeNow.AddDate(0, 0, -1),
},
}
//test
sortSmartMeasurementsDesc(smartResults)
//assert
require.Equal(t, smartResults[0].Date, timeNow)
}
+4 -1
View File
@@ -2,12 +2,15 @@ package database
import ( import (
"context" "context"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/models" "github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector" "github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
) )
// Create mock using:
// mockgen -source=webapp/backend/pkg/database/interface.go -destination=webapp/backend/pkg/database/mock/mock_database.go
type DeviceRepo interface { type DeviceRepo interface {
Close() error Close() error
HealthCheck(ctx context.Context) error HealthCheck(ctx context.Context) error
@@ -20,7 +23,7 @@ type DeviceRepo interface {
DeleteDevice(ctx context.Context, wwn string) error DeleteDevice(ctx context.Context, wwn string) error
SaveSmartAttributes(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (measurements.Smart, error) SaveSmartAttributes(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (measurements.Smart, error)
GetSmartAttributeHistory(ctx context.Context, wwn string, durationKey string, attributes []string) ([]measurements.Smart, error) GetSmartAttributeHistory(ctx context.Context, wwn string, durationKey string, selectEntries int, selectEntriesOffset int, attributes []string) ([]measurements.Smart, error)
SaveSmartTemperature(ctx context.Context, wwn string, deviceProtocol string, collectorSmartData collector.SmartInfo) error SaveSmartTemperature(ctx context.Context, wwn string, deviceProtocol string, collectorSmartData collector.SmartInfo) error
@@ -0,0 +1,258 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: webapp/backend/pkg/database/interface.go
// Package mock_database is a generated GoMock package.
package mock_database
import (
context "context"
reflect "reflect"
pkg "github.com/analogj/scrutiny/webapp/backend/pkg"
models "github.com/analogj/scrutiny/webapp/backend/pkg/models"
collector "github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
measurements "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
gomock "github.com/golang/mock/gomock"
)
// MockDeviceRepo is a mock of DeviceRepo interface.
type MockDeviceRepo struct {
ctrl *gomock.Controller
recorder *MockDeviceRepoMockRecorder
}
// MockDeviceRepoMockRecorder is the mock recorder for MockDeviceRepo.
type MockDeviceRepoMockRecorder struct {
mock *MockDeviceRepo
}
// NewMockDeviceRepo creates a new mock instance.
func NewMockDeviceRepo(ctrl *gomock.Controller) *MockDeviceRepo {
mock := &MockDeviceRepo{ctrl: ctrl}
mock.recorder = &MockDeviceRepoMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockDeviceRepo) EXPECT() *MockDeviceRepoMockRecorder {
return m.recorder
}
// Close mocks base method.
func (m *MockDeviceRepo) Close() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Close")
ret0, _ := ret[0].(error)
return ret0
}
// Close indicates an expected call of Close.
func (mr *MockDeviceRepoMockRecorder) Close() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockDeviceRepo)(nil).Close))
}
// DeleteDevice mocks base method.
func (m *MockDeviceRepo) DeleteDevice(ctx context.Context, wwn string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteDevice", ctx, wwn)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteDevice indicates an expected call of DeleteDevice.
func (mr *MockDeviceRepoMockRecorder) DeleteDevice(ctx, wwn interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDevice", reflect.TypeOf((*MockDeviceRepo)(nil).DeleteDevice), ctx, wwn)
}
// GetDeviceDetails mocks base method.
func (m *MockDeviceRepo) GetDeviceDetails(ctx context.Context, wwn string) (models.Device, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetDeviceDetails", ctx, wwn)
ret0, _ := ret[0].(models.Device)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetDeviceDetails indicates an expected call of GetDeviceDetails.
func (mr *MockDeviceRepoMockRecorder) GetDeviceDetails(ctx, wwn interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeviceDetails", reflect.TypeOf((*MockDeviceRepo)(nil).GetDeviceDetails), ctx, wwn)
}
// GetDevices mocks base method.
func (m *MockDeviceRepo) GetDevices(ctx context.Context) ([]models.Device, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetDevices", ctx)
ret0, _ := ret[0].([]models.Device)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetDevices indicates an expected call of GetDevices.
func (mr *MockDeviceRepoMockRecorder) GetDevices(ctx interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDevices", reflect.TypeOf((*MockDeviceRepo)(nil).GetDevices), ctx)
}
// GetSmartAttributeHistory mocks base method.
func (m *MockDeviceRepo) GetSmartAttributeHistory(ctx context.Context, wwn, durationKey string, selectEntries, selectEntriesOffset int, attributes []string) ([]measurements.Smart, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSmartAttributeHistory", ctx, wwn, durationKey, selectEntries, selectEntriesOffset, attributes)
ret0, _ := ret[0].([]measurements.Smart)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetSmartAttributeHistory indicates an expected call of GetSmartAttributeHistory.
func (mr *MockDeviceRepoMockRecorder) GetSmartAttributeHistory(ctx, wwn, durationKey, selectEntries, selectEntriesOffset, attributes interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartAttributeHistory", reflect.TypeOf((*MockDeviceRepo)(nil).GetSmartAttributeHistory), ctx, wwn, durationKey, selectEntries, selectEntriesOffset, attributes)
}
// GetSmartTemperatureHistory mocks base method.
func (m *MockDeviceRepo) GetSmartTemperatureHistory(ctx context.Context, durationKey string) (map[string][]measurements.SmartTemperature, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSmartTemperatureHistory", ctx, durationKey)
ret0, _ := ret[0].(map[string][]measurements.SmartTemperature)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetSmartTemperatureHistory indicates an expected call of GetSmartTemperatureHistory.
func (mr *MockDeviceRepoMockRecorder) GetSmartTemperatureHistory(ctx, durationKey interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartTemperatureHistory", reflect.TypeOf((*MockDeviceRepo)(nil).GetSmartTemperatureHistory), ctx, durationKey)
}
// GetSummary mocks base method.
func (m *MockDeviceRepo) GetSummary(ctx context.Context) (map[string]*models.DeviceSummary, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSummary", ctx)
ret0, _ := ret[0].(map[string]*models.DeviceSummary)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetSummary indicates an expected call of GetSummary.
func (mr *MockDeviceRepoMockRecorder) GetSummary(ctx interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSummary", reflect.TypeOf((*MockDeviceRepo)(nil).GetSummary), ctx)
}
// HealthCheck mocks base method.
func (m *MockDeviceRepo) HealthCheck(ctx context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HealthCheck", ctx)
ret0, _ := ret[0].(error)
return ret0
}
// HealthCheck indicates an expected call of HealthCheck.
func (mr *MockDeviceRepoMockRecorder) HealthCheck(ctx interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HealthCheck", reflect.TypeOf((*MockDeviceRepo)(nil).HealthCheck), ctx)
}
// LoadSettings mocks base method.
func (m *MockDeviceRepo) LoadSettings(ctx context.Context) (*models.Settings, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LoadSettings", ctx)
ret0, _ := ret[0].(*models.Settings)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LoadSettings indicates an expected call of LoadSettings.
func (mr *MockDeviceRepoMockRecorder) LoadSettings(ctx interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadSettings", reflect.TypeOf((*MockDeviceRepo)(nil).LoadSettings), ctx)
}
// RegisterDevice mocks base method.
func (m *MockDeviceRepo) RegisterDevice(ctx context.Context, dev models.Device) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RegisterDevice", ctx, dev)
ret0, _ := ret[0].(error)
return ret0
}
// RegisterDevice indicates an expected call of RegisterDevice.
func (mr *MockDeviceRepoMockRecorder) RegisterDevice(ctx, dev interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterDevice", reflect.TypeOf((*MockDeviceRepo)(nil).RegisterDevice), ctx, dev)
}
// SaveSettings mocks base method.
func (m *MockDeviceRepo) SaveSettings(ctx context.Context, settings models.Settings) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SaveSettings", ctx, settings)
ret0, _ := ret[0].(error)
return ret0
}
// SaveSettings indicates an expected call of SaveSettings.
func (mr *MockDeviceRepoMockRecorder) SaveSettings(ctx, settings interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSettings", reflect.TypeOf((*MockDeviceRepo)(nil).SaveSettings), ctx, settings)
}
// SaveSmartAttributes mocks base method.
func (m *MockDeviceRepo) SaveSmartAttributes(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (measurements.Smart, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SaveSmartAttributes", ctx, wwn, collectorSmartData)
ret0, _ := ret[0].(measurements.Smart)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SaveSmartAttributes indicates an expected call of SaveSmartAttributes.
func (mr *MockDeviceRepoMockRecorder) SaveSmartAttributes(ctx, wwn, collectorSmartData interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSmartAttributes", reflect.TypeOf((*MockDeviceRepo)(nil).SaveSmartAttributes), ctx, wwn, collectorSmartData)
}
// SaveSmartTemperature mocks base method.
func (m *MockDeviceRepo) SaveSmartTemperature(ctx context.Context, wwn, deviceProtocol string, collectorSmartData collector.SmartInfo) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SaveSmartTemperature", ctx, wwn, deviceProtocol, collectorSmartData)
ret0, _ := ret[0].(error)
return ret0
}
// SaveSmartTemperature indicates an expected call of SaveSmartTemperature.
func (mr *MockDeviceRepoMockRecorder) SaveSmartTemperature(ctx, wwn, deviceProtocol, collectorSmartData interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSmartTemperature", reflect.TypeOf((*MockDeviceRepo)(nil).SaveSmartTemperature), ctx, wwn, deviceProtocol, collectorSmartData)
}
// UpdateDevice mocks base method.
func (m *MockDeviceRepo) UpdateDevice(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (models.Device, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateDevice", ctx, wwn, collectorSmartData)
ret0, _ := ret[0].(models.Device)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateDevice indicates an expected call of UpdateDevice.
func (mr *MockDeviceRepoMockRecorder) UpdateDevice(ctx, wwn, collectorSmartData interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDevice", reflect.TypeOf((*MockDeviceRepo)(nil).UpdateDevice), ctx, wwn, collectorSmartData)
}
// UpdateDeviceStatus mocks base method.
func (m *MockDeviceRepo) UpdateDeviceStatus(ctx context.Context, wwn string, status pkg.DeviceStatus) (models.Device, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateDeviceStatus", ctx, wwn, status)
ret0, _ := ret[0].(models.Device)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateDeviceStatus indicates an expected call of UpdateDeviceStatus.
func (mr *MockDeviceRepoMockRecorder) UpdateDeviceStatus(ctx, wwn, status interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDeviceStatus", reflect.TypeOf((*MockDeviceRepo)(nil).UpdateDeviceStatus), ctx, wwn, status)
}
@@ -3,13 +3,14 @@ package database
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector" "github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
influxdb2 "github.com/influxdata/influxdb-client-go/v2" influxdb2 "github.com/influxdata/influxdb-client-go/v2"
"github.com/influxdata/influxdb-client-go/v2/api" "github.com/influxdata/influxdb-client-go/v2/api"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"strings"
"time"
) )
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -30,14 +31,17 @@ func (sr *scrutinyRepository) SaveSmartAttributes(ctx context.Context, wwn strin
} }
// GetSmartAttributeHistory MUST return in sorted order, where newest entries are at the beginning of the list, and oldest are at the end. // GetSmartAttributeHistory MUST return in sorted order, where newest entries are at the beginning of the list, and oldest are at the end.
func (sr *scrutinyRepository) GetSmartAttributeHistory(ctx context.Context, wwn string, durationKey string, attributes []string) ([]measurements.Smart, error) { // When selectEntries is > 0, only the most recent selectEntries database entries are returned, starting from the selectEntriesOffset entry.
// For example, with selectEntries = 5, selectEntries = 0, the most recent 5 are returned. With selectEntries = 3, selectEntries = 2, entries
// 2 to 4 are returned (2 being the third newest, since it is zero-indexed)
func (sr *scrutinyRepository) GetSmartAttributeHistory(ctx context.Context, wwn string, durationKey string, selectEntries int, selectEntriesOffset int, attributes []string) ([]measurements.Smart, error) {
// Get SMartResults from InfluxDB // Get SMartResults from InfluxDB
//TODO: change the filter startrange to a real number. //TODO: change the filter startrange to a real number.
// Get parser flux query result // Get parser flux query result
//appConfig.GetString("web.influxdb.bucket") //appConfig.GetString("web.influxdb.bucket")
queryStr := sr.aggregateSmartAttributesQuery(wwn, durationKey) queryStr := sr.aggregateSmartAttributesQuery(wwn, durationKey, selectEntries, selectEntriesOffset, attributes)
log.Infoln(queryStr) log.Infoln(queryStr)
smartResults := []measurements.Smart{} smartResults := []measurements.Smart{}
@@ -65,9 +69,6 @@ func (sr *scrutinyRepository) GetSmartAttributeHistory(ctx context.Context, wwn
return nil, err return nil, err
} }
//we have to sort the smartResults again, because the `union` command will return multiple 'tables' and only sort the records in each table.
sortSmartMeasurementsDesc(smartResults)
return smartResults, nil return smartResults, nil
//if err := device.SquashHistory(); err != nil { //if err := device.SquashHistory(); err != nil {
@@ -99,7 +100,7 @@ func (sr *scrutinyRepository) saveDatapoint(influxWriteApi api.WriteAPIBlocking,
return influxWriteApi.WritePoint(ctx, p) return influxWriteApi.WritePoint(ctx, p)
} }
func (sr *scrutinyRepository) aggregateSmartAttributesQuery(wwn string, durationKey string) string { func (sr *scrutinyRepository) aggregateSmartAttributesQuery(wwn string, durationKey string, selectEntries int, selectEntriesOffset int, attributes []string) string {
/* /*
@@ -108,28 +109,34 @@ func (sr *scrutinyRepository) aggregateSmartAttributesQuery(wwn string, duration
|> range(start: -1w, stop: now()) |> range(start: -1w, stop: now())
|> filter(fn: (r) => r["_measurement"] == "smart" ) |> filter(fn: (r) => r["_measurement"] == "smart" )
|> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" ) |> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" )
|> tail(n: 10, offset: 0)
|> schema.fieldsAsCols() |> schema.fieldsAsCols()
monthData = from(bucket: "metrics_weekly") monthData = from(bucket: "metrics_weekly")
|> range(start: -1mo, stop: -1w) |> range(start: -1mo, stop: -1w)
|> filter(fn: (r) => r["_measurement"] == "smart" ) |> filter(fn: (r) => r["_measurement"] == "smart" )
|> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" ) |> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" )
|> tail(n: 10, offset: 0)
|> schema.fieldsAsCols() |> schema.fieldsAsCols()
yearData = from(bucket: "metrics_monthly") yearData = from(bucket: "metrics_monthly")
|> range(start: -1y, stop: -1mo) |> range(start: -1y, stop: -1mo)
|> filter(fn: (r) => r["_measurement"] == "smart" ) |> filter(fn: (r) => r["_measurement"] == "smart" )
|> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" ) |> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" )
|> tail(n: 10, offset: 0)
|> schema.fieldsAsCols() |> schema.fieldsAsCols()
foreverData = from(bucket: "metrics_yearly") foreverData = from(bucket: "metrics_yearly")
|> range(start: -10y, stop: -1y) |> range(start: -10y, stop: -1y)
|> filter(fn: (r) => r["_measurement"] == "smart" ) |> filter(fn: (r) => r["_measurement"] == "smart" )
|> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" ) |> filter(fn: (r) => r["device_wwn"] == "0x5000c5002df89099" )
|> tail(n: 10, offset: 0)
|> schema.fieldsAsCols() |> schema.fieldsAsCols()
union(tables: [weekData, monthData, yearData, foreverData]) union(tables: [weekData, monthData, yearData, foreverData])
|> sort(columns: ["_time"], desc: false) |> group()
|> sort(columns: ["_time"], desc: true)
|> tail(n: 6, offset: 4)
|> yield(name: "last") |> yield(name: "last")
*/ */
@@ -140,34 +147,57 @@ func (sr *scrutinyRepository) aggregateSmartAttributesQuery(wwn string, duration
nestedDurationKeys := sr.lookupNestedDurationKeys(durationKey) nestedDurationKeys := sr.lookupNestedDurationKeys(durationKey)
subQueryNames := []string{} if len(nestedDurationKeys) == 1 {
for _, nestedDurationKey := range nestedDurationKeys {
bucketName := sr.lookupBucketName(nestedDurationKey)
durationRange := sr.lookupDuration(nestedDurationKey)
subQueryNames = append(subQueryNames, fmt.Sprintf(`%sData`, nestedDurationKey))
partialQueryStr = append(partialQueryStr, []string{
fmt.Sprintf(`%sData = from(bucket: "%s")`, nestedDurationKey, bucketName),
fmt.Sprintf(`|> range(start: %s, stop: %s)`, durationRange[0], durationRange[1]),
`|> filter(fn: (r) => r["_measurement"] == "smart" )`,
fmt.Sprintf(`|> filter(fn: (r) => r["device_wwn"] == "%s" )`, wwn),
"|> schema.fieldsAsCols()",
}...)
}
if len(subQueryNames) == 1 {
//there's only one bucket being queried, no need to union, just aggregate the dataset and return //there's only one bucket being queried, no need to union, just aggregate the dataset and return
partialQueryStr = append(partialQueryStr, []string{ partialQueryStr = append(partialQueryStr, []string{
subQueryNames[0], sr.generateSmartAttributesSubquery(wwn, nestedDurationKeys[0], selectEntries, selectEntriesOffset, attributes),
fmt.Sprintf(`%sData`, nestedDurationKeys[0]),
`|> sort(columns: ["_time"], desc: true)`,
`|> yield()`, `|> yield()`,
}...) }...)
} else { return strings.Join(partialQueryStr, "\n")
partialQueryStr = append(partialQueryStr, []string{
fmt.Sprintf("union(tables: [%s])", strings.Join(subQueryNames, ", ")),
`|> sort(columns: ["_time"], desc: false)`,
`|> yield(name: "last")`,
}...)
} }
subQueries := []string{}
subQueryNames := []string{}
for _, nestedDurationKey := range nestedDurationKeys {
subQueryNames = append(subQueryNames, fmt.Sprintf(`%sData`, nestedDurationKey))
if selectEntries > 0 {
// We only need the last `n + offset` # of entries from each table to guarantee we can
// get the last `n` # of entries starting from `offset` of the union
subQueries = append(subQueries, sr.generateSmartAttributesSubquery(wwn, nestedDurationKey, selectEntries+selectEntriesOffset, 0, attributes))
} else {
subQueries = append(subQueries, sr.generateSmartAttributesSubquery(wwn, nestedDurationKey, 0, 0, attributes))
}
}
partialQueryStr = append(partialQueryStr, subQueries...)
partialQueryStr = append(partialQueryStr, []string{
fmt.Sprintf("union(tables: [%s])", strings.Join(subQueryNames, ", ")),
`|> group()`,
`|> sort(columns: ["_time"], desc: true)`,
}...)
if selectEntries > 0 {
partialQueryStr = append(partialQueryStr, fmt.Sprintf(`|> tail(n: %d, offset: %d)`, selectEntries, selectEntriesOffset))
}
partialQueryStr = append(partialQueryStr, `|> yield(name: "last")`)
return strings.Join(partialQueryStr, "\n")
}
func (sr *scrutinyRepository) generateSmartAttributesSubquery(wwn string, durationKey string, selectEntries int, selectEntriesOffset int, attributes []string) string {
bucketName := sr.lookupBucketName(durationKey)
durationRange := sr.lookupDuration(durationKey)
partialQueryStr := []string{
fmt.Sprintf(`%sData = from(bucket: "%s")`, durationKey, bucketName),
fmt.Sprintf(`|> range(start: %s, stop: %s)`, durationRange[0], durationRange[1]),
`|> filter(fn: (r) => r["_measurement"] == "smart" )`,
fmt.Sprintf(`|> filter(fn: (r) => r["device_wwn"] == "%s" )`, wwn),
}
if selectEntries > 0 {
partialQueryStr = append(partialQueryStr, fmt.Sprintf(`|> tail(n: %d, offset: %d)`, selectEntries, selectEntriesOffset))
}
partialQueryStr = append(partialQueryStr, "|> schema.fieldsAsCols()")
return strings.Join(partialQueryStr, "\n") return strings.Join(partialQueryStr, "\n")
} }
@@ -4,6 +4,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strconv"
"time"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20201107210306" "github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20201107210306"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220503120000" "github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220503120000"
@@ -17,8 +20,6 @@ import (
"github.com/influxdata/influxdb-client-go/v2/api/http" "github.com/influxdata/influxdb-client-go/v2/api/http"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
"strconv"
"time"
) )
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -369,6 +370,21 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
return tx.Create(&defaultSettings).Error return tx.Create(&defaultSettings).Error
}, },
}, },
{
ID: "m20231123123300", // add repeat_notifications setting.
Migrate: func(tx *gorm.DB) error {
//add repeat_notifications setting default.
var defaultSettings = []m20220716214900.Setting{
{
SettingKeyName: "metrics.repeat_notifications",
SettingKeyDescription: "Whether to repeat all notifications or just when values change (true | false)",
SettingDataType: "bool",
SettingValueBool: true,
},
}
return tx.Create(&defaultSettings).Error
},
},
}) })
if err := m.Migrate(); err != nil { if err := m.Migrate(); err != nil {
+26 -12
View File
@@ -27,14 +27,11 @@ type SmartInfo struct {
Oui uint64 `json:"oui"` Oui uint64 `json:"oui"`
ID uint64 `json:"id"` ID uint64 `json:"id"`
} `json:"wwn"` } `json:"wwn"`
FirmwareVersion string `json:"firmware_version"` FirmwareVersion string `json:"firmware_version"`
UserCapacity struct { UserCapacity UserCapacity `json:"user_capacity"`
Blocks int64 `json:"blocks"` LogicalBlockSize int `json:"logical_block_size"`
Bytes int64 `json:"bytes"` PhysicalBlockSize int `json:"physical_block_size"`
} `json:"user_capacity"` RotationRate int `json:"rotation_rate"`
LogicalBlockSize int `json:"logical_block_size"`
PhysicalBlockSize int `json:"physical_block_size"`
RotationRate int `json:"rotation_rate"`
FormFactor struct { FormFactor struct {
AtaValue int `json:"ata_value"` AtaValue int `json:"ata_value"`
Name string `json:"name"` Name string `json:"name"`
@@ -210,9 +207,10 @@ type SmartInfo struct {
ID int `json:"id"` ID int `json:"id"`
SubsystemID int `json:"subsystem_id"` SubsystemID int `json:"subsystem_id"`
} `json:"nvme_pci_vendor"` } `json:"nvme_pci_vendor"`
NvmeIeeeOuiIdentifier int `json:"nvme_ieee_oui_identifier"` NvmeIeeeOuiIdentifier int `json:"nvme_ieee_oui_identifier"`
NvmeControllerID int `json:"nvme_controller_id"` NvmeTotalCapacity int64 `json:"nvme_total_capacity"`
NvmeNumberOfNamespaces int `json:"nvme_number_of_namespaces"` NvmeControllerID int `json:"nvme_controller_id"`
NvmeNumberOfNamespaces int `json:"nvme_number_of_namespaces"`
NvmeNamespaces []struct { NvmeNamespaces []struct {
ID int `json:"id"` ID int `json:"id"`
Size struct { Size struct {
@@ -239,7 +237,23 @@ type SmartInfo struct {
ScsiErrorCounterLog ScsiErrorCounterLog `json:"scsi_error_counter_log"` ScsiErrorCounterLog ScsiErrorCounterLog `json:"scsi_error_counter_log"`
} }
//Primary Attribute Structs // Capacity finds the total capacity of the device in bytes, or 0 if unknown.
func (s *SmartInfo) Capacity() int64 {
switch {
case s.NvmeTotalCapacity > 0:
return s.NvmeTotalCapacity
case s.UserCapacity.Bytes > 0:
return s.UserCapacity.Bytes
}
return 0
}
type UserCapacity struct {
Blocks int64 `json:"blocks"`
Bytes int64 `json:"bytes"`
}
// Primary Attribute Structs
type AtaSmartAttributesTableItem struct { type AtaSmartAttributesTableItem struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -0,0 +1,33 @@
package collector
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSmartInfo_Capacity(t *testing.T) {
t.Run("should report nvme capacity", func(t *testing.T) {
smartInfo := SmartInfo{
UserCapacity: UserCapacity{
Bytes: 1234,
},
NvmeTotalCapacity: 5678,
}
assert.Equal(t, int64(5678), smartInfo.Capacity())
})
t.Run("should report user capacity", func(t *testing.T) {
smartInfo := SmartInfo{
UserCapacity: UserCapacity{
Bytes: 1234,
},
}
assert.Equal(t, int64(1234), smartInfo.Capacity())
})
t.Run("should report 0 for unknown capacities", func(t *testing.T) {
var smartInfo SmartInfo
assert.Zero(t, smartInfo.Capacity())
})
}
@@ -2,10 +2,11 @@ package measurements
import ( import (
"fmt" "fmt"
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"strconv" "strconv"
"strings" "strings"
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
) )
type SmartAtaAttribute struct { type SmartAtaAttribute struct {
@@ -24,6 +25,10 @@ type SmartAtaAttribute struct {
FailureRate float64 `json:"failure_rate,omitempty"` FailureRate float64 `json:"failure_rate,omitempty"`
} }
func (sa *SmartAtaAttribute) GetTransformedValue() int64 {
return sa.TransformedValue
}
func (sa *SmartAtaAttribute) GetStatus() pkg.AttributeStatus { func (sa *SmartAtaAttribute) GetStatus() pkg.AttributeStatus {
return sa.Status return sa.Status
} }
@@ -6,4 +6,5 @@ type SmartAttribute interface {
Flatten() (fields map[string]interface{}) Flatten() (fields map[string]interface{})
Inflate(key string, val interface{}) Inflate(key string, val interface{})
GetStatus() pkg.AttributeStatus GetStatus() pkg.AttributeStatus
GetTransformedValue() int64
} }
@@ -2,9 +2,10 @@ package measurements
import ( import (
"fmt" "fmt"
"strings"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds" "github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"strings"
) )
type SmartNvmeAttribute struct { type SmartNvmeAttribute struct {
@@ -18,6 +19,10 @@ type SmartNvmeAttribute struct {
FailureRate float64 `json:"failure_rate,omitempty"` FailureRate float64 `json:"failure_rate,omitempty"`
} }
func (sa *SmartNvmeAttribute) GetTransformedValue() int64 {
return sa.TransformedValue
}
func (sa *SmartNvmeAttribute) GetStatus() pkg.AttributeStatus { func (sa *SmartNvmeAttribute) GetStatus() pkg.AttributeStatus {
return sa.Status return sa.Status
} }
@@ -2,9 +2,10 @@ package measurements
import ( import (
"fmt" "fmt"
"strings"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds" "github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"strings"
) )
type SmartScsiAttribute struct { type SmartScsiAttribute struct {
@@ -18,6 +19,10 @@ type SmartScsiAttribute struct {
FailureRate float64 `json:"failure_rate,omitempty"` FailureRate float64 `json:"failure_rate,omitempty"`
} }
func (sa *SmartScsiAttribute) GetTransformedValue() int64 {
return sa.TransformedValue
}
func (sa *SmartScsiAttribute) GetStatus() pkg.AttributeStatus { func (sa *SmartScsiAttribute) GetStatus() pkg.AttributeStatus {
return sa.Status return sa.Status
} }
+4 -3
View File
@@ -17,8 +17,9 @@ type Settings struct {
LineStroke string `json:"line_stroke" mapstructure:"line_stroke"` LineStroke string `json:"line_stroke" mapstructure:"line_stroke"`
Metrics struct { Metrics struct {
NotifyLevel int `json:"notify_level" mapstructure:"notify_level"` NotifyLevel int `json:"notify_level" mapstructure:"notify_level"`
StatusFilterAttributes int `json:"status_filter_attributes" mapstructure:"status_filter_attributes"` StatusFilterAttributes int `json:"status_filter_attributes" mapstructure:"status_filter_attributes"`
StatusThreshold int `json:"status_threshold" mapstructure:"status_threshold"` StatusThreshold int `json:"status_threshold" mapstructure:"status_threshold"`
RepeatNotifications bool `json:"repeat_notifications" mapstructure:"repeat_notifications"`
} `json:"metrics" mapstructure:"metrics"` } `json:"metrics" mapstructure:"metrics"`
} }
+4 -3
View File
@@ -4,13 +4,14 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"io" "io"
"io/ioutil" "io/ioutil"
"log" "log"
"net/http" "net/http"
"os" "os"
"time" "time"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
) )
func main() { func main() {
@@ -32,7 +33,7 @@ func main() {
log.Fatalf("ERROR %v", err) log.Fatalf("ERROR %v", err)
} }
defer file.Close() defer file.Close()
_, err = SendPostRequest("http://localhost:9090/api/devices/register", file) _, err = SendPostRequest("http://localhost:8080/api/devices/register", file)
if err != nil { if err != nil {
log.Fatalf("ERROR %v", err) log.Fatalf("ERROR %v", err)
} }
@@ -46,7 +47,7 @@ func main() {
log.Fatalf("ERROR %v", err) log.Fatalf("ERROR %v", err)
} }
_, err = SendPostRequest(fmt.Sprintf("http://localhost:9090/api/device/%s/smart", diskId), smartDataReader) _, err = SendPostRequest(fmt.Sprintf("http://localhost:8080/api/device/%s/smart", diskId), smartDataReader)
if err != nil { if err != nil {
log.Fatalf("ERROR %v", err) log.Fatalf("ERROR %v", err)
} }
+69 -46
View File
@@ -5,22 +5,25 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/analogj/go-util/utils"
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/config"
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"github.com/containrrr/shoutrrr"
shoutrrrTypes "github.com/containrrr/shoutrrr/pkg/types"
"github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/analogj/go-util/utils"
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/config"
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"github.com/containrrr/shoutrrr"
shoutrrrTypes "github.com/containrrr/shoutrrr/pkg/types"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
) )
const NotifyFailureTypeEmailTest = "EmailTest" const NotifyFailureTypeEmailTest = "EmailTest"
@@ -29,7 +32,7 @@ const NotifyFailureTypeSmartFailure = "SmartFailure"
const NotifyFailureTypeScrutinyFailure = "ScrutinyFailure" const NotifyFailureTypeScrutinyFailure = "ScrutinyFailure"
// ShouldNotify check if the error Message should be filtered (level mismatch or filtered_attributes) // ShouldNotify check if the error Message should be filtered (level mismatch or filtered_attributes)
func ShouldNotify(device models.Device, smartAttrs measurements.Smart, statusThreshold pkg.MetricsStatusThreshold, statusFilterAttributes pkg.MetricsStatusFilterAttributes) bool { func ShouldNotify(logger logrus.FieldLogger, device models.Device, smartAttrs measurements.Smart, statusThreshold pkg.MetricsStatusThreshold, statusFilterAttributes pkg.MetricsStatusFilterAttributes, repeatNotifications bool, c *gin.Context, deviceRepo database.DeviceRepo) bool {
// 1. check if the device is healthy // 1. check if the device is healthy
if device.DeviceStatus == pkg.DeviceStatusPassed { if device.DeviceStatus == pkg.DeviceStatusPassed {
return false return false
@@ -53,52 +56,69 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, statusThr
requiredAttrStatus = pkg.AttributeStatusFailedScrutiny requiredAttrStatus = pkg.AttributeStatusFailedScrutiny
} }
// 2. check if the attributes that are failing should be filtered (non-critical) // This is the only case where individual attributes need not be considered
// 3. for any unfiltered attribute, store the failure reason (Smart or Scrutiny) if statusFilterAttributes == pkg.MetricsStatusFilterAttributesAll && repeatNotifications {
if statusFilterAttributes == pkg.MetricsStatusFilterAttributesCritical { return pkg.DeviceStatusHas(device.DeviceStatus, requiredDeviceStatus)
hasFailingCriticalAttr := false }
var statusFailingCriticalAttr pkg.AttributeStatus
for attrId, attrData := range smartAttrs.Attributes { var failingAttributes []string
//find failing attribute // Loop through the attributes to find the failing ones
if attrData.GetStatus() == pkg.AttributeStatusPassed { for attrId, attrData := range smartAttrs.Attributes {
continue //skip all passing attributes var status pkg.AttributeStatus = attrData.GetStatus()
} // Skip over passing attributes
if status == pkg.AttributeStatusPassed {
continue
}
// merge the status's of all critical attributes // If the user only wants to consider critical attributes, we have to check
statusFailingCriticalAttr = pkg.AttributeStatusSet(statusFailingCriticalAttr, attrData.GetStatus()) // if the not-passing attribute is critical or not
if statusFilterAttributes == pkg.MetricsStatusFilterAttributesCritical {
//found a failing attribute, see if its critical critical := false
if device.IsScsi() && thresholds.ScsiMetadata[attrId].Critical { if device.IsScsi() {
hasFailingCriticalAttr = true critical = thresholds.ScsiMetadata[attrId].Critical
} else if device.IsNvme() && thresholds.NmveMetadata[attrId].Critical { } else if device.IsNvme() {
hasFailingCriticalAttr = true critical = thresholds.NmveMetadata[attrId].Critical
} else { } else {
//this is ATA //this is ATA
attrIdInt, err := strconv.Atoi(attrId) attrIdInt, err := strconv.Atoi(attrId)
if err != nil { if err != nil {
continue continue
} }
if thresholds.AtaMetadata[attrIdInt].Critical { critical = thresholds.AtaMetadata[attrIdInt].Critical
hasFailingCriticalAttr = true }
} // Skip non-critical, non-passing attributes when this setting is on
if !critical {
continue
} }
} }
if !hasFailingCriticalAttr { // Record any attribute that doesn't get skipped by the above two checks
//no critical attributes are failing, and notifyFilterAttributes == "critical" failingAttributes = append(failingAttributes, attrId)
return false
} else {
// check if any of the critical attributes have a status that we're looking for
return pkg.AttributeStatusHas(statusFailingCriticalAttr, requiredAttrStatus)
}
} else {
// 2. SKIP - we are processing every attribute.
// 3. check if the device failure level matches the wanted failure level.
return pkg.DeviceStatusHas(device.DeviceStatus, requiredDeviceStatus)
} }
// If the user doesn't want repeated notifications when the failing value doesn't change, we need to get the last value from the db
var lastPoints []measurements.Smart
var err error
if !repeatNotifications {
lastPoints, err = deviceRepo.GetSmartAttributeHistory(c, c.Param("wwn"), database.DURATION_KEY_FOREVER, 1, 1, failingAttributes)
if err == nil || len(lastPoints) < 1 {
logger.Warningln("Could not get the most recent data points from the database. This is expected to happen only if this is the very first submission of data for the device.")
}
}
for _, attrId := range failingAttributes {
attrStatus := smartAttrs.Attributes[attrId].GetStatus()
if pkg.AttributeStatusHas(attrStatus, requiredAttrStatus) {
if repeatNotifications {
return true
}
// This is checked again here to avoid repeating the entire for loop in the check above.
// Probably unnoticeably worse performance, but cleaner code.
if err != nil || len(lastPoints) < 1 || lastPoints[0].Attributes[attrId].GetTransformedValue() != smartAttrs.Attributes[attrId].GetTransformedValue() {
return true
}
}
}
return false
} }
// TODO: include user label for device. // TODO: include user label for device.
@@ -221,7 +241,7 @@ func (n *Notify) Send() error {
notifyScripts := []string{} notifyScripts := []string{}
notifyShoutrrr := []string{} notifyShoutrrr := []string{}
for ndx, _ := range configUrls { for ndx := range configUrls {
if strings.HasPrefix(configUrls[ndx], "https://") || strings.HasPrefix(configUrls[ndx], "http://") { if strings.HasPrefix(configUrls[ndx], "https://") || strings.HasPrefix(configUrls[ndx], "http://") {
notifyWebhooks = append(notifyWebhooks, configUrls[ndx]) notifyWebhooks = append(notifyWebhooks, configUrls[ndx])
} else if strings.HasPrefix(configUrls[ndx], "script://") { } else if strings.HasPrefix(configUrls[ndx], "script://") {
@@ -386,6 +406,9 @@ func (n *Notify) GenShoutrrrNotificationParams(shoutrrrUrl string) (string, *sho
case "join": case "join":
(*params)["title"] = subject (*params)["title"] = subject
(*params)["icon"] = logoUrl (*params)["icon"] = logoUrl
case "ntfy":
(*params)["title"] = subject
(*params)["icon"] = logoUrl
case "opsgenie": case "opsgenie":
(*params)["title"] = subject (*params)["title"] = subject
case "pushbullet": case "pushbullet":
+113 -17
View File
@@ -1,13 +1,20 @@
package notify package notify
import ( import (
"errors"
"fmt" "fmt"
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"github.com/stretchr/testify/require"
"testing" "testing"
"time" "time"
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
mock_database "github.com/analogj/scrutiny/webapp/backend/pkg/database/mock"
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"github.com/gin-gonic/gin"
"github.com/golang/mock/gomock"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
) )
func TestShouldNotify_MustSkipPassingDevices(t *testing.T) { func TestShouldNotify_MustSkipPassingDevices(t *testing.T) {
@@ -20,22 +27,27 @@ func TestShouldNotify_MustSkipPassingDevices(t *testing.T) {
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusThresholdBoth_FailingSmartDevice(t *testing.T) { func TestShouldNotify_MetricsStatusThresholdBoth_FailingSmartDevice(t *testing.T) {
t.Parallel() t.Parallel()
//setup //setupD
device := models.Device{ device := models.Device{
DeviceStatus: pkg.DeviceStatusFailedSmart, DeviceStatus: pkg.DeviceStatusFailedSmart,
} }
smartAttrs := measurements.Smart{} smartAttrs := measurements.Smart{}
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusThresholdSmart_FailingSmartDevice(t *testing.T) { func TestShouldNotify_MetricsStatusThresholdSmart_FailingSmartDevice(t *testing.T) {
@@ -47,9 +59,11 @@ func TestShouldNotify_MetricsStatusThresholdSmart_FailingSmartDevice(t *testing.
smartAttrs := measurements.Smart{} smartAttrs := measurements.Smart{}
statusThreshold := pkg.MetricsStatusThresholdSmart statusThreshold := pkg.MetricsStatusThresholdSmart
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusThresholdScrutiny_FailingSmartDevice(t *testing.T) { func TestShouldNotify_MetricsStatusThresholdScrutiny_FailingSmartDevice(t *testing.T) {
@@ -61,9 +75,11 @@ func TestShouldNotify_MetricsStatusThresholdScrutiny_FailingSmartDevice(t *testi
smartAttrs := measurements.Smart{} smartAttrs := measurements.Smart{}
statusThreshold := pkg.MetricsStatusThresholdScrutiny statusThreshold := pkg.MetricsStatusThresholdScrutiny
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithCriticalAttrs(t *testing.T) { func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithCriticalAttrs(t *testing.T) {
@@ -79,9 +95,12 @@ func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithCriticalAttrs(t
}} }}
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithMultipleCriticalAttrs(t *testing.T) { func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithMultipleCriticalAttrs(t *testing.T) {
@@ -100,9 +119,12 @@ func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithMultipleCritical
}} }}
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoCriticalAttrs(t *testing.T) { func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoCriticalAttrs(t *testing.T) {
@@ -118,9 +140,12 @@ func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoCriticalAttrs(
}} }}
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoFailingCriticalAttrs(t *testing.T) { func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoFailingCriticalAttrs(t *testing.T) {
@@ -136,9 +161,12 @@ func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoFailingCritica
}} }}
statusThreshold := pkg.MetricsStatusThresholdBoth statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
} }
func TestShouldNotify_MetricsStatusFilterAttributesCritical_MetricsStatusThresholdSmart_WithCriticalAttrsFailingScrutiny(t *testing.T) { func TestShouldNotify_MetricsStatusFilterAttributesCritical_MetricsStatusThresholdSmart_WithCriticalAttrsFailingScrutiny(t *testing.T) {
@@ -157,9 +185,77 @@ func TestShouldNotify_MetricsStatusFilterAttributesCritical_MetricsStatusThresho
}} }}
statusThreshold := pkg.MetricsStatusThresholdSmart statusThreshold := pkg.MetricsStatusThresholdSmart
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
//assert //assert
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes)) require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, true, &gin.Context{}, fakeDatabase))
}
func TestShouldNotify_NoRepeat_DatabaseFailure(t *testing.T) {
t.Parallel()
//setup
device := models.Device{
DeviceStatus: pkg.DeviceStatusFailedScrutiny,
}
smartAttrs := measurements.Smart{Attributes: map[string]measurements.SmartAttribute{
"5": &measurements.SmartAtaAttribute{
Status: pkg.AttributeStatusFailedScrutiny,
},
}}
statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
fakeDatabase.EXPECT().GetSmartAttributeHistory(&gin.Context{}, "", database.DURATION_KEY_FOREVER, 1, 1, []string{"5"}).Return([]measurements.Smart{}, errors.New("")).Times(1)
//assert
require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, false, &gin.Context{}, fakeDatabase))
}
func TestShouldNotify_NoRepeat_NoDatabaseData(t *testing.T) {
t.Parallel()
//setup
device := models.Device{
DeviceStatus: pkg.DeviceStatusFailedScrutiny,
}
smartAttrs := measurements.Smart{Attributes: map[string]measurements.SmartAttribute{
"5": &measurements.SmartAtaAttribute{
Status: pkg.AttributeStatusFailedScrutiny,
},
}}
statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
fakeDatabase.EXPECT().GetSmartAttributeHistory(&gin.Context{}, "", database.DURATION_KEY_FOREVER, 1, 1, []string{"5"}).Return([]measurements.Smart{}, nil).Times(1)
//assert
require.True(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, false, &gin.Context{}, fakeDatabase))
}
func TestShouldNotify_NoRepeat(t *testing.T) {
t.Parallel()
//setup
device := models.Device{
DeviceStatus: pkg.DeviceStatusFailedScrutiny,
}
smartAttrs := measurements.Smart{Attributes: map[string]measurements.SmartAttribute{
"5": &measurements.SmartAtaAttribute{
Status: pkg.AttributeStatusFailedScrutiny,
TransformedValue: 0,
},
}}
statusThreshold := pkg.MetricsStatusThresholdBoth
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeDatabase := mock_database.NewMockDeviceRepo(mockCtrl)
fakeDatabase.EXPECT().GetSmartAttributeHistory(&gin.Context{}, "", database.DURATION_KEY_FOREVER, 1, 1, []string{"5"}).Return([]measurements.Smart{smartAttrs}, nil).Times(1)
//assert
require.False(t, ShouldNotify(logrus.StandardLogger(), device, smartAttrs, statusThreshold, notifyFilterAttributes, false, &gin.Context{}, fakeDatabase))
} }
func TestNewPayload(t *testing.T) { func TestNewPayload(t *testing.T) {
@@ -1,5 +1,10 @@
package thresholds package thresholds
import (
"strconv"
"strings"
)
const AtaSmartAttributeDisplayTypeRaw = "raw" const AtaSmartAttributeDisplayTypeRaw = "raw"
const AtaSmartAttributeDisplayTypeNormalized = "normalized" const AtaSmartAttributeDisplayTypeNormalized = "normalized"
const AtaSmartAttributeDisplayTypeTransformed = "transformed" const AtaSmartAttributeDisplayTypeTransformed = "transformed"
@@ -662,62 +667,84 @@ var AtaMetadata = map[int]AtaAttributeMetadata{
188: { 188: {
ID: 188, ID: 188,
DisplayName: "Command Timeout", DisplayName: "Command Timeout",
DisplayType: AtaSmartAttributeDisplayTypeRaw, DisplayType: AtaSmartAttributeDisplayTypeTransformed,
Ideal: ObservedThresholdIdealLow, Ideal: ObservedThresholdIdealLow,
Critical: true, Critical: true,
Description: "The count of aborted operations due to HDD timeout. Normally this attribute value should be equal to zero.", Description: "The count of aborted operations due to HDD timeout. Normally this attribute value should be equal to zero.",
Transform: func(normValue int64, rawValue int64, rawString string) int64 {
// Parse Seagate command timeout values if the string contains 3 pieces
// and each piece is less than or equal to the next (as a sanity check)
// See https://github.com/AnalogJ/scrutiny/issues/522
pieces := strings.Split(rawString, " ")
if len(pieces) == 3 {
int_pieces := make([]int, len(pieces))
var err error
for i, s := range pieces {
int_pieces[i], err = strconv.Atoi(s)
if err != nil {
return rawValue
}
}
if int_pieces[2] >= int_pieces[1] && int_pieces[1] >= int_pieces[0] {
return int64(int_pieces[2])
}
}
return rawValue
},
ObservedThresholds: []ObservedThreshold{ ObservedThresholds: []ObservedThreshold{
{ {
Low: 0, Low: 0,
High: 0, // This is set arbitrarily to avoid notifications caused by low
// historical numbers of command timeouts (e.g. caused by a bad cable)
High: 100,
AnnualFailureRate: 0.024893587674442153, AnnualFailureRate: 0.024893587674442153,
ErrorInterval: []float64{0.020857343769186413, 0.0294830350167543}, ErrorInterval: []float64{0.020857343769186413, 0.0294830350167543},
}, },
{ {
Low: 0, Low: 100,
High: 13, High: 13000000000,
AnnualFailureRate: 0.10044174089362015, AnnualFailureRate: 0.10044174089362015,
ErrorInterval: []float64{0.0812633664077498, 0.1227848196758574}, ErrorInterval: []float64{0.0812633664077498, 0.1227848196758574},
}, },
{ {
Low: 13, Low: 13000000000,
High: 26, High: 26000000000,
AnnualFailureRate: 0.334030592234279, AnnualFailureRate: 0.334030592234279,
ErrorInterval: []float64{0.2523231196342665, 0.4337665082489293}, ErrorInterval: []float64{0.2523231196342665, 0.4337665082489293},
}, },
{ {
Low: 26, Low: 26000000000,
High: 39, High: 39000000000,
AnnualFailureRate: 0.36724705400842445, AnnualFailureRate: 0.36724705400842445,
ErrorInterval: []float64{0.30398009356575617, 0.4397986538328568}, ErrorInterval: []float64{0.30398009356575617, 0.4397986538328568},
}, },
{ {
Low: 39, Low: 39000000000,
High: 52, High: 52000000000,
AnnualFailureRate: 0.29848155926978354, AnnualFailureRate: 0.29848155926978354,
ErrorInterval: []float64{0.2509254838615984, 0.35242890006477073}, ErrorInterval: []float64{0.2509254838615984, 0.35242890006477073},
}, },
{ {
Low: 52, Low: 52000000000,
High: 65, High: 65000000000,
AnnualFailureRate: 0.2203079701535098, AnnualFailureRate: 0.2203079701535098,
ErrorInterval: []float64{0.18366082845676174, 0.26212468677179274}, ErrorInterval: []float64{0.18366082845676174, 0.26212468677179274},
}, },
{ {
Low: 65, Low: 65000000000,
High: 78, High: 78000000000,
AnnualFailureRate: 0.3018169948863018, AnnualFailureRate: 0.3018169948863018,
ErrorInterval: []float64{0.23779746376787655, 0.37776897542831006}, ErrorInterval: []float64{0.23779746376787655, 0.37776897542831006},
}, },
{ {
Low: 78, Low: 78000000000,
High: 91, High: 91000000000,
AnnualFailureRate: 0.32854928239235887, AnnualFailureRate: 0.32854928239235887,
ErrorInterval: []float64{0.2301118782147336, 0.4548506948185028}, ErrorInterval: []float64{0.2301118782147336, 0.4548506948185028},
}, },
{ {
Low: 91, Low: 91000000000,
High: 104, High: 104000000000,
AnnualFailureRate: 0.28488916640649387, AnnualFailureRate: 0.28488916640649387,
ErrorInterval: []float64{0.1366154288236293, 0.5239213202729072}, ErrorInterval: []float64{0.1366154288236293, 0.5239213202729072},
}, },
+1 -1
View File
@@ -2,4 +2,4 @@ package version
// VERSION is the app-global version string, which will be replaced with a // VERSION is the app-global version string, which will be replaced with a
// new value during packaging // new value during packaging
const VERSION = "0.7.0" const VERSION = "0.7.3"
@@ -1,11 +1,12 @@
package handler package handler
import ( import (
"net/http"
"github.com/analogj/scrutiny/webapp/backend/pkg/database" "github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds" "github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"net/http"
) )
func GetDeviceDetails(c *gin.Context) { func GetDeviceDetails(c *gin.Context) {
@@ -24,7 +25,7 @@ func GetDeviceDetails(c *gin.Context) {
durationKey = "forever" durationKey = "forever"
} }
smartResults, err := deviceRepo.GetSmartAttributeHistory(c, c.Param("wwn"), durationKey, nil) smartResults, err := deviceRepo.GetSmartAttributeHistory(c, c.Param("wwn"), durationKey, 0, 0, nil)
if err != nil { if err != nil {
logger.Errorln("An error occurred while retrieving device smart results", err) logger.Errorln("An error occurred while retrieving device smart results", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false}) c.JSON(http.StatusInternalServerError, gin.H{"success": false})
@@ -2,6 +2,8 @@ package handler
import ( import (
"fmt" "fmt"
"net/http"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/config"
"github.com/analogj/scrutiny/webapp/backend/pkg/database" "github.com/analogj/scrutiny/webapp/backend/pkg/database"
@@ -9,7 +11,6 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg/notify" "github.com/analogj/scrutiny/webapp/backend/pkg/notify"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"net/http"
) )
func UploadDeviceMetrics(c *gin.Context) { func UploadDeviceMetrics(c *gin.Context) {
@@ -69,10 +70,14 @@ func UploadDeviceMetrics(c *gin.Context) {
//check for error //check for error
if notify.ShouldNotify( if notify.ShouldNotify(
logger,
updatedDevice, updatedDevice,
smartData, smartData,
pkg.MetricsStatusThreshold(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY))), pkg.MetricsStatusThreshold(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY))),
pkg.MetricsStatusFilterAttributes(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY))), pkg.MetricsStatusFilterAttributes(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY))),
appConfig.GetBool(fmt.Sprintf("%s.metrics.repeat_notifications", config.DB_USER_SETTINGS_SUBKEY)),
c,
deviceRepo,
) { ) {
//send notifications //send notifications
+13 -9
View File
@@ -4,6 +4,16 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path"
"strings"
"testing"
"time"
"github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/config"
mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock" mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock"
@@ -14,15 +24,6 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path"
"strings"
"testing"
"time"
) )
/* /*
@@ -189,6 +190,7 @@ func (suite *ServerTestSuite) TestUploadDeviceMetricsRoute() {
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("user.metrics.repeat_notifications").Return(true).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions { if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
@@ -247,6 +249,7 @@ func (suite *ServerTestSuite) TestPopulateMultiple() {
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("user.metrics.repeat_notifications").Return(true).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions { if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
@@ -529,6 +532,7 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.token").Return("my-super-secret-auth-token").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes() fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("user.metrics.repeat_notifications").Return(true).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes() fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{}) fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{})
@@ -55,6 +55,7 @@ export interface AppConfig {
notify_level?: MetricsNotifyLevel notify_level?: MetricsNotifyLevel
status_filter_attributes?: MetricsStatusFilterAttributes status_filter_attributes?: MetricsStatusFilterAttributes
status_threshold?: MetricsStatusThreshold status_threshold?: MetricsStatusThreshold
repeat_notifications?: boolean
} }
} }
@@ -82,7 +83,8 @@ export const appConfig: AppConfig = {
metrics: { metrics: {
notify_level: MetricsNotifyLevel.Fail, notify_level: MetricsNotifyLevel.Fail,
status_filter_attributes: MetricsStatusFilterAttributes.All, status_filter_attributes: MetricsStatusFilterAttributes.All,
status_threshold: MetricsStatusThreshold.Both status_threshold: MetricsStatusThreshold.Both,
repeat_notifications: true
} }
}; };
@@ -1,10 +1,12 @@
<h2 mat-dialog-title>Delete {{data.title}}?</h2> <h2 mat-dialog-title>Delete {{data.title}}?</h2>
<mat-dialog-content>This will delete all data associated with this device (including all historical data).</mat-dialog-content> <mat-dialog-content>This will remove the device and all historical data from Scrutiny. <strong>Any data on the device
itself will remain untouched.</strong></mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>
<button mat-button mat-dialog-close>Cancel</button> <button mat-button mat-dialog-close>Cancel</button>
<!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. --> <!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. -->
<button class="red-600" mat-button (click)="onDeleteClick()"> <button class="red-600" mat-button (click)="onDeleteClick()">
<mat-icon class="icon-size-20 mr-3" <mat-icon class="icon-size-20 mr-3"
[svgIcon]="'delete_forever'"></mat-icon> [svgIcon]="'delete_forever'"></mat-icon>
Delete</button> Delete
</button>
</mat-dialog-actions> </mat-dialog-actions>
@@ -84,6 +84,16 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
<mat-label>Repeat Notifications</mat-label>
<mat-select [(ngModel)]=repeatNotifications>
<mat-option [value]=true>Always</mat-option>
<mat-option [value]=false>Only when the value has changed</mat-option>
</mat-select>
</mat-form-field>
</div>
</div> </div>
</mat-dialog-content> </mat-dialog-content>
@@ -28,6 +28,7 @@ export class DashboardSettingsComponent implements OnInit {
theme: string; theme: string;
statusThreshold: number; statusThreshold: number;
statusFilterAttributes: number; statusFilterAttributes: number;
repeatNotifications: boolean;
// Private // Private
private _unsubscribeAll: Subject<void>; private _unsubscribeAll: Subject<void>;
@@ -55,6 +56,7 @@ export class DashboardSettingsComponent implements OnInit {
this.statusFilterAttributes = config.metrics.status_filter_attributes; this.statusFilterAttributes = config.metrics.status_filter_attributes;
this.statusThreshold = config.metrics.status_threshold; this.statusThreshold = config.metrics.status_threshold;
this.repeatNotifications = config.metrics.repeat_notifications;
}); });
@@ -70,7 +72,8 @@ export class DashboardSettingsComponent implements OnInit {
theme: this.theme as Theme, theme: this.theme as Theme,
metrics: { metrics: {
status_filter_attributes: this.statusFilterAttributes as MetricsStatusFilterAttributes, status_filter_attributes: this.statusFilterAttributes as MetricsStatusFilterAttributes,
status_threshold: this.statusThreshold as MetricsStatusThreshold status_threshold: this.statusThreshold as MetricsStatusThreshold,
repeat_notifications: this.repeatNotifications
} }
} }
this._configService.config = newSettings this._configService.config = newSettings