Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1e5bd3ed4 | |||
| d8d56f77f9 | |||
| 26b221532e | |||
| 15d3206f6f | |||
| 59e2e928a8 | |||
| 51f59e4fcd | |||
| f823127825 | |||
| d41d535ab7 | |||
| 9a4a8de341 | |||
| 2d6f60abaa | |||
| d201f798fb | |||
| a1b0108503 | |||
| f0275d2349 | |||
| 9dafde8a43 | |||
| fa8f86ab7b | |||
| 41c9daa939 | |||
| 83186ba36e | |||
| 3205e3d022 | |||
| 3f272b36d4 | |||
| b238579fe6 | |||
| ce2f990eb1 | |||
| b11b8732aa | |||
| 5cd441da7b | |||
| 2e768fb491 | |||
| e8755ff617 | |||
| e41ee47371 | |||
| 7a68a68e76 | |||
| 94594db20a | |||
| 7e672e8b8e | |||
| 54e2cacb00 | |||
| c0f1dfdb0b | |||
| 29bc79996b | |||
| 99af2b8b16 | |||
| dd0c3e6fba | |||
| 5b2746f389 | |||
| e9c1de9664 | |||
| 6ca4bd4912 | |||
| c34ee85e48 | |||
| 91e8eb1def | |||
| a01b8fe083 | |||
| 550fb542d4 | |||
| 7841063783 | |||
| 8e05b2e2f8 | |||
| 64e1c93d16 | |||
| b227054b52 | |||
| 66bd6f99c5 | |||
| c6579864b8 | |||
| 2361c329e2 | |||
| 5ea149d878 | |||
| 30bd18f816 | |||
| 0f0efac866 | |||
| 04563c0d0d | |||
| 9316eccabe | |||
| b71d6660a6 | |||
| 0e2fec4e93 | |||
| ff171282cc | |||
| ea8fe208d0 |
@@ -3,11 +3,25 @@ name: CI
|
|||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test-frontend:
|
||||||
name: Test
|
name: Test Frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Test Frontend
|
||||||
|
run: |
|
||||||
|
make binary-frontend-test-coverage
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: coverage
|
||||||
|
path: ${{ github.workspace }}/webapp/frontend/coverage/lcov.info
|
||||||
|
retention-days: 1
|
||||||
|
test-backend:
|
||||||
|
name: Test Backend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: ghcr.io/packagrio/packagr:latest-golang
|
container: ghcr.io/packagrio/packagr:latest-golang
|
||||||
|
|
||||||
# Service containers to run with `build` (Required for end-to-end testing)
|
# Service containers to run with `build` (Required for end-to-end testing)
|
||||||
services:
|
services:
|
||||||
influxdb:
|
influxdb:
|
||||||
@@ -22,7 +36,6 @@ jobs:
|
|||||||
ports:
|
ports:
|
||||||
- 8086:8086
|
- 8086:8086
|
||||||
env:
|
env:
|
||||||
PROJECT_PATH: /go/src/github.com/analogj/scrutiny
|
|
||||||
STATIC: true
|
STATIC: true
|
||||||
steps:
|
steps:
|
||||||
- name: Git
|
- name: Git
|
||||||
@@ -32,16 +45,36 @@ jobs:
|
|||||||
git --version
|
git --version
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Test
|
- name: Test Backend
|
||||||
run: |
|
run: |
|
||||||
make binary-clean binary-test-coverage
|
make binary-clean binary-test-coverage
|
||||||
- name: Generate coverage report
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: coverage
|
||||||
|
path: ${{ github.workspace }}/coverage.txt
|
||||||
|
retention-days: 1
|
||||||
|
test-coverage:
|
||||||
|
name: Test Coverage Upload
|
||||||
|
needs:
|
||||||
|
- test-backend
|
||||||
|
- test-frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Download coverage reports
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: coverage
|
||||||
|
- name: Upload coverage reports
|
||||||
uses: codecov/codecov-action@v2
|
uses: codecov/codecov-action@v2
|
||||||
with:
|
with:
|
||||||
files: ${{ github.workspace }}/coverage.txt
|
files: ${{ github.workspace }}/coverage.txt,${{ github.workspace }}/lcov.info
|
||||||
flags: unittests
|
flags: unittests
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
verbose: true
|
verbose: true
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build ${{ matrix.cfg.goos }}/${{ matrix.cfg.goarch }}
|
name: Build ${{ matrix.cfg.goos }}/${{ matrix.cfg.goarch }}
|
||||||
runs-on: ${{ matrix.cfg.on }}
|
runs-on: ${{ matrix.cfg.on }}
|
||||||
@@ -66,6 +99,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: '^1.18.3'
|
||||||
- name: Build Binaries
|
- name: Build Binaries
|
||||||
run: |
|
run: |
|
||||||
make binary-clean binary-all
|
make binary-clean binary-all
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ jobs:
|
|||||||
run: "cd webapp/frontend && ./git.version.sh"
|
run: "cd webapp/frontend && ./git.version.sh"
|
||||||
- name: Build Frontend
|
- name: Build Frontend
|
||||||
run: |
|
run: |
|
||||||
|
apt-get update && apt-get install -y make
|
||||||
make binary-frontend
|
make binary-frontend
|
||||||
tar -czf scrutiny-web-frontend.tar.gz dist
|
tar -czf scrutiny-web-frontend.tar.gz dist
|
||||||
- name: Upload Frontend Asset
|
- name: Upload Frontend Asset
|
||||||
|
|||||||
+4
-3
@@ -9,8 +9,9 @@ Depending on the functionality you are adding, you may need to setup a developme
|
|||||||
|
|
||||||
# Modifying the Scrutiny Backend Server (API)
|
# Modifying the Scrutiny Backend Server (API)
|
||||||
|
|
||||||
1. install the [Go runtime](https://go.dev/doc/install) (v1.17+)
|
1. install the [Go runtime](https://go.dev/doc/install) (v1.18+)
|
||||||
2. download the `scrutiny-web-frontend.tar.gz` for the [latest release](https://github.com/AnalogJ/scrutiny/releases/latest). Extract to a folder named `dist`
|
2. download the `scrutiny-web-frontend.tar.gz` for
|
||||||
|
the [latest release](https://github.com/AnalogJ/scrutiny/releases/latest). Extract to a folder named `dist`
|
||||||
3. create a `scrutiny.yaml` config file
|
3. create a `scrutiny.yaml` config file
|
||||||
```yaml
|
```yaml
|
||||||
# config file for local development. store as scrutiny.yaml
|
# config file for local development. store as scrutiny.yaml
|
||||||
@@ -62,7 +63,7 @@ The frontend is written in Angular. If you're working on the frontend and can us
|
|||||||
If you're developing a feature that requires changes to the backend and the frontend, or a frontend feature that requires real data,
|
If you're developing a feature that requires changes to the backend and the frontend, or a frontend feature that requires real data,
|
||||||
you'll need to follow the steps below:
|
you'll need to follow the steps below:
|
||||||
|
|
||||||
1. install the [Go runtime](https://go.dev/doc/install) (v1.17+)
|
1. install the [Go runtime](https://go.dev/doc/install) (v1.18+)
|
||||||
2. install [NodeJS](https://nodejs.org/en/download/)
|
2. install [NodeJS](https://nodejs.org/en/download/)
|
||||||
3. create a `scrutiny.yaml` config file
|
3. create a `scrutiny.yaml` config file
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.ONESHELL: # Applies to every targets in the file! .ONESHELL instructs make to invoke a single instance of the shell and provide it with the entire recipe, regardless of how many lines it contains.
|
.ONESHELL: # Applies to every targets in the file! .ONESHELL instructs make to invoke a single instance of the shell and provide it with the entire recipe, regardless of how many lines it contains.
|
||||||
|
.SHELLFLAGS = -ec
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
# Global Env Settings
|
# Global Env Settings
|
||||||
@@ -89,6 +90,10 @@ ifneq ($(OS),Windows_NT)
|
|||||||
./$(WEB_BINARY_NAME) || true
|
./$(WEB_BINARY_NAME) || true
|
||||||
endif
|
endif
|
||||||
|
|
||||||
|
########################################################################################################################
|
||||||
|
# Binary
|
||||||
|
########################################################################################################################
|
||||||
|
|
||||||
.PHONY: binary-frontend
|
.PHONY: binary-frontend
|
||||||
# reduce logging, disable angular-cli analytics for ci environment
|
# reduce logging, disable angular-cli analytics for ci environment
|
||||||
binary-frontend: export NPM_CONFIG_LOGLEVEL = warn
|
binary-frontend: export NPM_CONFIG_LOGLEVEL = warn
|
||||||
@@ -100,6 +105,12 @@ binary-frontend:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build:prod -- --output-path=$(CURDIR)/dist
|
npm run build:prod -- --output-path=$(CURDIR)/dist
|
||||||
|
|
||||||
|
.PHONY: binary-frontend-test-coverage
|
||||||
|
# reduce logging, disable angular-cli analytics for ci environment
|
||||||
|
binary-frontend-test-coverage:
|
||||||
|
cd webapp/frontend
|
||||||
|
npm ci
|
||||||
|
npx ng test --watch=false --browsers=ChromeHeadless --code-coverage
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
# Docker
|
# Docker
|
||||||
|
|||||||
@@ -239,9 +239,9 @@ scrutiny-collector-metrics run --debug --log-file /tmp/collector.log
|
|||||||
| linux-arm-6 | :white_check_mark: | |
|
| linux-arm-6 | :white_check_mark: | |
|
||||||
| linux-arm-7 | :white_check_mark: | web/collector only. see [#236](https://github.com/AnalogJ/scrutiny/issues/236) |
|
| linux-arm-7 | :white_check_mark: | web/collector only. see [#236](https://github.com/AnalogJ/scrutiny/issues/236) |
|
||||||
| linux-arm64 | :white_check_mark: | :white_check_mark: |
|
| linux-arm64 | :white_check_mark: | :white_check_mark: |
|
||||||
| freebsd-amd64 | collector only. see [#238](https://github.com/AnalogJ/scrutiny/issues/238) | |
|
| freebsd-amd64 | :white_check_mark: | |
|
||||||
| macos-amd64 | | :white_check_mark: |
|
| macos-amd64 | :white_check_mark: | :white_check_mark: |
|
||||||
| macos-arm64 | | :white_check_mark: |
|
| macos-arm64 | :white_check_mark: | :white_check_mark: |
|
||||||
| windows-amd64 | :white_check_mark: | WIP, see [#15](https://github.com/AnalogJ/scrutiny/issues/15) |
|
| windows-amd64 | :white_check_mark: | WIP, see [#15](https://github.com/AnalogJ/scrutiny/issues/15) |
|
||||||
| windows-arm64 | :white_check_mark: | |
|
| windows-arm64 | :white_check_mark: | |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/analogj/scrutiny/collector/pkg/collector"
|
"github.com/analogj/scrutiny/collector/pkg/collector"
|
||||||
"github.com/analogj/scrutiny/collector/pkg/config"
|
"github.com/analogj/scrutiny/collector/pkg/config"
|
||||||
@@ -120,26 +121,16 @@ OPTIONS:
|
|||||||
config.Set("api.endpoint", apiEndpoint)
|
config.Set("api.endpoint", apiEndpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
collectorLogger := logrus.WithFields(logrus.Fields{
|
collectorLogger, logFile, err := CreateLogger(config)
|
||||||
"type": "metrics",
|
if logFile != nil {
|
||||||
})
|
|
||||||
|
|
||||||
if level, err := logrus.ParseLevel(config.GetString("log.level")); err == nil {
|
|
||||||
logrus.SetLevel(level)
|
|
||||||
} else {
|
|
||||||
logrus.SetLevel(logrus.InfoLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.IsSet("log.file") && len(config.GetString("log.file")) > 0 {
|
|
||||||
logFile, err := os.OpenFile(config.GetString("log.file"), os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Errorf("Failed to open log file %s for output: %s", config.GetString("log.file"), err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer logFile.Close()
|
defer logFile.Close()
|
||||||
logrus.SetOutput(io.MultiWriter(os.Stderr, logFile))
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
settingsData, err := json.MarshalIndent(config.AllSettings(), "", "\t")
|
||||||
|
collectorLogger.Debug(string(settingsData), err)
|
||||||
metricCollector, err := collector.CreateMetricsCollector(
|
metricCollector, err := collector.CreateMetricsCollector(
|
||||||
config,
|
config,
|
||||||
collectorLogger,
|
collectorLogger,
|
||||||
@@ -192,5 +183,28 @@ OPTIONS:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(color.HiRedString("ERROR: %v", err))
|
log.Fatal(color.HiRedString("ERROR: %v", err))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateLogger(appConfig config.Interface) (*logrus.Entry, *os.File, error) {
|
||||||
|
logger := logrus.WithFields(logrus.Fields{
|
||||||
|
"type": "metrics",
|
||||||
|
})
|
||||||
|
|
||||||
|
if level, err := logrus.ParseLevel(appConfig.GetString("log.level")); err == nil {
|
||||||
|
logger.Logger.SetLevel(level)
|
||||||
|
} else {
|
||||||
|
logger.Logger.SetLevel(logrus.InfoLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var logFile *os.File
|
||||||
|
var err error
|
||||||
|
if appConfig.IsSet("log.file") && len(appConfig.GetString("log.file")) > 0 {
|
||||||
|
logFile, err = os.OpenFile(appConfig.GetString("log.file"), os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Errorf("Failed to open log file %s for output: %s", appConfig.GetString("log.file"), err)
|
||||||
|
return nil, logFile, err
|
||||||
|
}
|
||||||
|
logger.Logger.SetOutput(io.MultiWriter(os.Stderr, logFile))
|
||||||
|
}
|
||||||
|
return logger, logFile, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/analogj/scrutiny/collector/pkg/detect"
|
"github.com/analogj/scrutiny/collector/pkg/detect"
|
||||||
"github.com/analogj/scrutiny/collector/pkg/errors"
|
"github.com/analogj/scrutiny/collector/pkg/errors"
|
||||||
"github.com/analogj/scrutiny/collector/pkg/models"
|
"github.com/analogj/scrutiny/collector/pkg/models"
|
||||||
|
"github.com/samber/lo"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -56,11 +57,16 @@ func (mc *MetricsCollector) Run() error {
|
|||||||
Logger: mc.logger,
|
Logger: mc.logger,
|
||||||
Config: mc.config,
|
Config: mc.config,
|
||||||
}
|
}
|
||||||
detectedStorageDevices, err := deviceDetector.Start()
|
rawDetectedStorageDevices, err := deviceDetector.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//filter any device with empty wwn (they are invalid)
|
||||||
|
detectedStorageDevices := lo.Filter[models.Device](rawDetectedStorageDevices, func(dev models.Device, _ int) bool {
|
||||||
|
return len(dev.WWN) > 0
|
||||||
|
})
|
||||||
|
|
||||||
mc.logger.Infoln("Sending detected devices to API, for filtering & validation")
|
mc.logger.Infoln("Sending detected devices to API, for filtering & validation")
|
||||||
jsonObj, _ := json.Marshal(detectedStorageDevices)
|
jsonObj, _ := json.Marshal(detectedStorageDevices)
|
||||||
mc.logger.Debugf("Detected devices: %v", string(jsonObj))
|
mc.logger.Debugf("Detected devices: %v", string(jsonObj))
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
|
|
||||||
########
|
########
|
||||||
FROM golang:1.17-bullseye as backendbuild
|
FROM golang:1.18-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 . /go/src/github.com/analogj/scrutiny
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
|
|
||||||
########
|
########
|
||||||
FROM golang:1.17-bullseye as backendbuild
|
FROM golang:1.18-bullseye as backendbuild
|
||||||
|
|
||||||
WORKDIR /go/src/github.com/analogj/scrutiny
|
WORKDIR /go/src/github.com/analogj/scrutiny
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ RUN make binary-clean binary-collector
|
|||||||
|
|
||||||
########
|
########
|
||||||
FROM debian:bullseye-slim as runtime
|
FROM debian:bullseye-slim as runtime
|
||||||
WORKDIR /scrutiny
|
WORKDIR /opt/scrutiny
|
||||||
ENV PATH="/opt/scrutiny/bin:${PATH}"
|
ENV PATH="/opt/scrutiny/bin:${PATH}"
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y cron smartmontools ca-certificates tzdata && update-ca-certificates
|
RUN apt-get update && apt-get install -y cron smartmontools ca-certificates tzdata && update-ca-certificates
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
|
|
||||||
########
|
########
|
||||||
FROM golang:1.17-bullseye as backendbuild
|
FROM golang:1.18-bullseye as backendbuild
|
||||||
|
|
||||||
WORKDIR /go/src/github.com/analogj/scrutiny
|
WORKDIR /go/src/github.com/analogj/scrutiny
|
||||||
|
|
||||||
|
|||||||
@@ -91,9 +91,13 @@ wget https://raw.githubusercontent.com/smartmontools/smartmontools/master/smartm
|
|||||||
```
|
```
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
/volume1/\@Entware/scrutiny/bin/scrutiny-collector-metrics-linux-arm64 run --config /volume1/\@Entware/scrutiny/config/collector.yaml
|
/volume1/\@Entware/scrutiny/bin/scrutiny-collector-metrics-linux-arm64 run --config /volume1/\@Entware/scrutiny/conf/collector.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Make `run_collect.sh` executable**
|
||||||
|
|
||||||
|
`chmod +x /volume1/\@Entware/scrutiny/bin/run_collect.sh`
|
||||||
|
|
||||||
## Set up Synology to run a scheduled task.
|
## Set up Synology to run a scheduled task.
|
||||||
|
|
||||||
Log in to DSM and do the following:
|
Log in to DSM and do the following:
|
||||||
@@ -131,4 +135,4 @@ Frequency: <Your desired frequency>
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
If you have any issues with your devices being detected, or incorrect data, please take a look at [TROUBLESHOOTING_DEVICE_COLLECTOR.md](./TROUBLESHOOTING_DEVICE_COLLECTOR.md)
|
If you have any issues with your devices being detected, or incorrect data, please take a look at [TROUBLESHOOTING_DEVICE_COLLECTOR.md](./TROUBLESHOOTING_DEVICE_COLLECTOR.md)
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
# Officially Supported NAS OS's
|
# Officially Supported NAS/OS's
|
||||||
|
|
||||||
These are the officially supported NAS OS's (with documentation and setup guides).
|
These are the officially supported NAS OS's (with documentation and setup guides). Once a guide is created (
|
||||||
Once a guide is created (in `docs/guides/`) it will be linked here.
|
in `docs/guides/` or elsewhere) it will be linked here.
|
||||||
|
|
||||||
- [ ] freenas/truenas
|
- [x] [freenas/truenas](https://blog.stefandroid.com/2022/01/14/smart-scrutiny.html)
|
||||||
- [x] [unraid](./INSTALL_UNRAID.md)
|
- [x] [unraid](./INSTALL_UNRAID.md)
|
||||||
- [ ] ESXI
|
- [ ] ESXI
|
||||||
- [ ] Proxmox
|
- [ ] Proxmox
|
||||||
- [x] Synology(./INSTALL_SYNOLOGY_COLLECTOR.md)
|
- [x] [Synology](./INSTALL_SYNOLOGY_COLLECTOR.md)
|
||||||
- [ ] OMV
|
- [ ] OMV
|
||||||
- [ ] Amahi
|
- [ ] Amahi
|
||||||
- [ ] Running in a LXC container
|
- [ ] Running in a LXC container
|
||||||
- [x] [PFSense](./INSTALL_UNRAID.md)
|
- [x] [PFSense](./INSTALL_UNRAID.md)
|
||||||
- [ ] QNAP
|
- [x] QNAP
|
||||||
- [ ] RockStor
|
- [x] [RockStor](https://rockstor.com/docs/interface/docker-based-rock-ons/scrutiny.html)
|
||||||
|
- [ ] Solaris/OmniOS CE Support
|
||||||
|
- [ ] Kubernetes
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ devices:
|
|||||||
As mentioned in the [README.md](/README.md), NVMe devices require both `--cap-add SYS_RAWIO` and `--cap-add SYS_ADMIN`
|
As mentioned in the [README.md](/README.md), NVMe devices require both `--cap-add SYS_RAWIO` and `--cap-add SYS_ADMIN`
|
||||||
to allow smartctl permission to query your NVMe device SMART data [#26](https://github.com/AnalogJ/scrutiny/issues/26)
|
to allow smartctl permission to query your NVMe device SMART data [#26](https://github.com/AnalogJ/scrutiny/issues/26)
|
||||||
|
|
||||||
When attaching NVMe devices using `--device=/dev/nvme..`, make sure to provide the device controller (`/dev/nvme0`)
|
When attaching NVMe devices using `--device=/dev/nvme..`, make sure to provide the device controller (`/dev/nvme0`)
|
||||||
instead of the block device (`/dev/nvme0n1`). See [#209](https://github.com/AnalogJ/scrutiny/issues/209).
|
instead of the block device (`/dev/nvme0n1`). See [#209](https://github.com/AnalogJ/scrutiny/issues/209).
|
||||||
|
|
||||||
> The character device /dev/nvme0 is the NVME device controller, and block devices like /dev/nvme0n1 are the NVME storage namespaces: the devices you use for actual storage, which will behave essentially as disks.
|
> The character device /dev/nvme0 is the NVME device controller, and block devices like /dev/nvme0n1 are the NVME storage namespaces: the devices you use for actual storage, which will behave essentially as disks.
|
||||||
@@ -113,15 +113,29 @@ instead of the block device (`/dev/nvme0n1`). See [#209](https://github.com/Anal
|
|||||||
|
|
||||||
### ATA
|
### ATA
|
||||||
|
|
||||||
|
### USB Devices
|
||||||
|
|
||||||
|
The following information is extracted from [#266](https://github.com/AnalogJ/scrutiny/issues/266)
|
||||||
|
|
||||||
|
External HDDs support two modes of operation usb-storage (old, slower, stable) and uas (new, faster, sometimes unstable)
|
||||||
|
. On some external HDDs, uas mode does not properly pass through SMART information, or even causes hardware issues, so
|
||||||
|
it has been disabled by the kernel. No amount of smartctl parameters will fix this, as it is being rejected by the
|
||||||
|
kernel. This is especially true with Seagate HDDs. One solution is to force these devices into usb-storage mode, which
|
||||||
|
will incur some performance penalty, but may work well enough for you. More info:
|
||||||
|
|
||||||
|
- https://smartmontools.org/wiki/Supported_USB-Devices
|
||||||
|
- https://smartmontools.org/wiki/SAT-with-UAS-Linux
|
||||||
|
- https://forums.raspberrypi.com/viewtopic.php?t=245931
|
||||||
|
|
||||||
### Exit Codes
|
### Exit Codes
|
||||||
|
|
||||||
If you see an error message similar to `smartctl returned an error code (2) while processing /dev/sda`, this means that
|
If you see an error message similar to `smartctl returned an error code (2) while processing /dev/sda`, this means that
|
||||||
`smartctl` (not Scrutiny) exited with an error code. Scrutiny will attempt to print a helpful error message to help you debug,
|
`smartctl` (not Scrutiny) exited with an error code. Scrutiny will attempt to print a helpful error message to help you
|
||||||
but you can look at the table (and associated links) below to debug `smartctl`.
|
debug, but you can look at the table (and associated links) below to debug `smartctl`.
|
||||||
|
|
||||||
> smartctl Return Values
|
> smartctl Return Values
|
||||||
> The return values of smartctl are defined by a bitmask. If all is well with the disk, the return value (exit status) of
|
> The return values of smartctl are defined by a bitmask. If all is well with the disk, the return value (exit status) of
|
||||||
> smartctl is 0 (all bits turned off). If a problem occurs, or an error, potential error, or fault is detected, then
|
> smartctl is 0 (all bits turned off). If a problem occurs, or an error, potential error, or fault is detected, then
|
||||||
> a non-zero status is returned. In this case, the eight different bits in the return value have the following meanings
|
> a non-zero status is returned. In this case, the eight different bits in the return value have the following meanings
|
||||||
> for ATA disks; some of these values may also be returned for SCSI disks.
|
> for ATA disks; some of these values may also be returned for SCSI disks.
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
# InfluxDB Troubleshooting
|
# InfluxDB Troubleshooting
|
||||||
|
|
||||||
## Installation
|
## Why??
|
||||||
InfluxDB is a required dependency for Scrutiny v0.4.0+.
|
|
||||||
|
Scrutiny has many features, but the relevant one to this conversation is the "S.M.A.R.T metric tracking for historical
|
||||||
|
trends". Basically Scrutiny not only shows you the current SMART values, but how they've changed over weeks, months (or
|
||||||
|
even years).
|
||||||
|
|
||||||
|
To efficiently handle that data at scale (and to make my life easier as a developer) I decided to add InfluxDB as a
|
||||||
|
dependency. It's a dedicated timeseries database, as opposed to the general purpose sqlite DB I used before. I also did
|
||||||
|
a bunch of testing and analysis before I made the change. With InfluxDB the memory footprint for Scrutiny (at idle) is ~
|
||||||
|
100mb, which is still fairly reasonable.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
InfluxDB is a required dependency for Scrutiny v0.4.0+.
|
||||||
|
|
||||||
https://docs.influxdata.com/influxdb/v2.2/install/
|
https://docs.influxdata.com/influxdb/v2.2/install/
|
||||||
|
|
||||||
@@ -66,12 +78,319 @@ panic: failed to check influxdb setup status - parse "://:": missing protocol sc
|
|||||||
As discussed in [#248](https://github.com/AnalogJ/scrutiny/issues/248) and [#234](https://github.com/AnalogJ/scrutiny/issues/234),
|
As discussed in [#248](https://github.com/AnalogJ/scrutiny/issues/248) and [#234](https://github.com/AnalogJ/scrutiny/issues/234),
|
||||||
this usually related to either:
|
this usually related to either:
|
||||||
|
|
||||||
- Upgrading from the LSIO Scrutiny image to the Official Scrutiny image, without removing LSIO specific environmental variables
|
- Upgrading from the LSIO Scrutiny image to the Official Scrutiny image, without removing LSIO specific environmental
|
||||||
- remove the `SCRUTINY_WEB=true` and `SCRUTINY_COLLECTOR=true` environmental variables. They were used by the LSIO image, but are unnecessary and cause issues with the official Scrutiny image.
|
variables
|
||||||
- Updated versions of the [LSIO Scrutiny images are broken](https://github.com/linuxserver/docker-scrutiny/issues/22), as they have not installed InfluxDB which is a required dependency of Scrutiny v0.4.x
|
- remove the `SCRUTINY_WEB=true` and `SCRUTINY_COLLECTOR=true` environmental variables. They were used by the LSIO
|
||||||
- You can revert to an earlier version of the LSIO image (`lscr.io/linuxserver/scrutiny:060ac7b8-ls34`), or just change to the official Scrutiny image (`ghcr.io/analogj/scrutiny:master-omnibus`)
|
image, but are unnecessary and cause issues with the official Scrutiny image.
|
||||||
|
- Updated versions of the [LSIO Scrutiny images are broken](https://github.com/linuxserver/docker-scrutiny/issues/22),
|
||||||
|
as they have not installed InfluxDB which is a required dependency of Scrutiny v0.4.x
|
||||||
|
- You can revert to an earlier version of the LSIO image (`lscr.io/linuxserver/scrutiny:060ac7b8-ls34`), or just
|
||||||
|
change to the official Scrutiny image (`ghcr.io/analogj/scrutiny:master-omnibus`)
|
||||||
|
|
||||||
Here's a couple of confirmed working docker-compose files that you may want to look at:
|
Here's a couple of confirmed working docker-compose files that you may want to look at:
|
||||||
|
|
||||||
- https://github.com/AnalogJ/scrutiny/blob/master/docker/example.hubspoke.docker-compose.yml
|
- https://github.com/AnalogJ/scrutiny/blob/master/docker/example.hubspoke.docker-compose.yml
|
||||||
- https://github.com/AnalogJ/scrutiny/blob/master/docker/example.omnibus.docker-compose.yml
|
- https://github.com/AnalogJ/scrutiny/blob/master/docker/example.omnibus.docker-compose.yml
|
||||||
|
|
||||||
|
## Bring your own InfluxDB
|
||||||
|
|
||||||
|
> WARNING: Most users should not follow these steps. This is ONLY for users who have an EXISTING InfluxDB installation which contains data from multiple services.
|
||||||
|
> The Scrutiny Docker omnibus image includes an empty InfluxDB instance which it can configure.
|
||||||
|
> If you're deploying manually or via Hub/Spoke, you can just follow the installation instructions, Scrutiny knows how
|
||||||
|
> to run the first-time setup automatically.
|
||||||
|
|
||||||
|
The goal here is to create an InfluxDB API key with minimal permissions for use by Scrutiny.
|
||||||
|
|
||||||
|
- Create Scrutiny buckets (`metrics`, `metrics_weekly`, `metrics_monthly`, `metrics_yearly`) with placeholder config
|
||||||
|
- Create Downsampling tasks (`tsk-weekly-aggr`, `tsk-monthly-aggr`, `tsk-yearly-aggr`) with placeholder script.
|
||||||
|
- Create API token with restricted scope
|
||||||
|
- NOTE: Placeholder bucket & task configuration will be replaced automatically by Scrutiny during startup
|
||||||
|
|
||||||
|
The placeholder buckets and tasks need to be created before the API token can be created, as the resource ID's need to
|
||||||
|
exist for the scope restriction to work.
|
||||||
|
|
||||||
|
Scopes:
|
||||||
|
|
||||||
|
- `orgs`: read - required for scrutiny to find it's configured org_id
|
||||||
|
- `tasks`: scrutiny specific read/write access - Scrutiny only needs access to the downsampling tasks you created above
|
||||||
|
- `buckets`: scrutiny specific read/write access - Scrutiny only needs access to the buckets you created above
|
||||||
|
|
||||||
|
### Setup Environmental Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# replace the following values with correct values for your InfluxDB installation
|
||||||
|
export INFLUXDB_ADMIN_TOKEN=pCqRq7xxxxxx-FZgNLfstIs0w==
|
||||||
|
export INFLUXDB_ORG_ID=b2495xxxxx
|
||||||
|
export INFLUXDB_HOSTNAME=http://localhost:8086
|
||||||
|
|
||||||
|
# if you want to change the bucket name prefix below, you'll also need to update the setting in the scrutiny.yaml config file.
|
||||||
|
export INFLUXDB_SCRUTINY_BUCKET_BASENAME=metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create placeholder buckets
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand!</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/buckets \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
|
||||||
|
--data-binary @- << EOF
|
||||||
|
{
|
||||||
|
"name": "${INFLUXDB_SCRUTINY_BUCKET_BASENAME}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}",
|
||||||
|
"retentionRules": []
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/buckets \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
|
||||||
|
--data-binary @- << EOF
|
||||||
|
{
|
||||||
|
"name": "${INFLUXDB_SCRUTINY_BUCKET_BASENAME}_weekly",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}",
|
||||||
|
"retentionRules": []
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/buckets \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
|
||||||
|
--data-binary @- << EOF
|
||||||
|
{
|
||||||
|
"name": "${INFLUXDB_SCRUTINY_BUCKET_BASENAME}_monthly",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}",
|
||||||
|
"retentionRules": []
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/buckets \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
|
||||||
|
--data-binary @- << EOF
|
||||||
|
{
|
||||||
|
"name": "${INFLUXDB_SCRUTINY_BUCKET_BASENAME}_yearly",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}",
|
||||||
|
"retentionRules": []
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Create placeholder tasks
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand!</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/tasks \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
|
||||||
|
--data-binary @- << EOF
|
||||||
|
{
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}",
|
||||||
|
"flux": "option task = {name: \"tsk-weekly-aggr\", every: 1y} \nyield now()"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/tasks \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
|
||||||
|
--data-binary @- << EOF
|
||||||
|
{
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}",
|
||||||
|
"flux": "option task = {name: \"tsk-monthly-aggr\", every: 1y} \nyield now()"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/tasks \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
|
||||||
|
--data-binary @- << EOF
|
||||||
|
{
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}",
|
||||||
|
"flux": "option task = {name: \"tsk-yearly-aggr\", every: 1y} \nyield now()"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Create InfluxDB API Token
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand!</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# replace these values with placeholder bucket and task ids from your InfluxDB installation.
|
||||||
|
export INFLUXDB_SCRUTINY_BASE_BUCKET_ID=1e0709xxxx
|
||||||
|
export INFLUXDB_SCRUTINY_WEEKLY_BUCKET_ID=1af03dexxxxx
|
||||||
|
export INFLUXDB_SCRUTINY_MONTHLY_BUCKET_ID=b3c59c7xxxxx
|
||||||
|
export INFLUXDB_SCRUTINY_YEARLY_BUCKET_ID=f381d8cxxxxx
|
||||||
|
|
||||||
|
export INFLUXDB_SCRUTINY_WEEKLY_TASK_ID=09a64ecxxxxx
|
||||||
|
export INFLUXDB_SCRUTINY_MONTHLY_TASK_ID=09a64xxxxx
|
||||||
|
export INFLUXDB_SCRUTINY_YEARLY_TASK_ID=09a64ecxxxxx
|
||||||
|
|
||||||
|
|
||||||
|
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/authorizations \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
|
||||||
|
--data-binary @- << EOF
|
||||||
|
{
|
||||||
|
"description": "scrutiny - restricted scope token",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"action": "read",
|
||||||
|
"resource": {
|
||||||
|
"type": "orgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "read",
|
||||||
|
"resource": {
|
||||||
|
"type": "tasks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "write",
|
||||||
|
"resource": {
|
||||||
|
"type": "tasks",
|
||||||
|
"id": "${INFLUXDB_SCRUTINY_WEEKLY_TASK_ID}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "write",
|
||||||
|
"resource": {
|
||||||
|
"type": "tasks",
|
||||||
|
"id": "${INFLUXDB_SCRUTINY_MONTHLY_TASK_ID}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "write",
|
||||||
|
"resource": {
|
||||||
|
"type": "tasks",
|
||||||
|
"id": "${INFLUXDB_SCRUTINY_YEARLY_TASK_ID}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "read",
|
||||||
|
"resource": {
|
||||||
|
"type": "buckets",
|
||||||
|
"id": "${INFLUXDB_SCRUTINY_BASE_BUCKET_ID}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "write",
|
||||||
|
"resource": {
|
||||||
|
"type": "buckets",
|
||||||
|
"id": "${INFLUXDB_SCRUTINY_BASE_BUCKET_ID}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "read",
|
||||||
|
"resource": {
|
||||||
|
"type": "buckets",
|
||||||
|
"id": "${INFLUXDB_SCRUTINY_WEEKLY_BUCKET_ID}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "write",
|
||||||
|
"resource": {
|
||||||
|
"type": "buckets",
|
||||||
|
"id": "${INFLUXDB_SCRUTINY_WEEKLY_BUCKET_ID}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "read",
|
||||||
|
"resource": {
|
||||||
|
"type": "buckets",
|
||||||
|
"id": "${INFLUXDB_SCRUTINY_MONTHLY_BUCKET_ID}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "write",
|
||||||
|
"resource": {
|
||||||
|
"type": "buckets",
|
||||||
|
"id": "${INFLUXDB_SCRUTINY_MONTHLY_BUCKET_ID}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "read",
|
||||||
|
"resource": {
|
||||||
|
"type": "buckets",
|
||||||
|
"id": "${INFLUXDB_SCRUTINY_YEARLY_BUCKET_ID}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "write",
|
||||||
|
"resource": {
|
||||||
|
"type": "buckets",
|
||||||
|
"id": "${INFLUXDB_SCRUTINY_YEARLY_BUCKET_ID}",
|
||||||
|
"orgID": "${INFLUXDB_ORG_ID}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Save InfluxDB API Token
|
||||||
|
|
||||||
|
After running the Curl command above, you'll see a JSON response that looks like the following:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "ksVU2t5SkQwYkvIxxxxxxxYt2xUt0uRKSbSF1Po0UQ==",
|
||||||
|
"status": "active",
|
||||||
|
"description": "scrutiny - restricted scope token",
|
||||||
|
"orgID": "b2495586xxxx",
|
||||||
|
"org": "my-org",
|
||||||
|
"user": "admin",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"action": "read",
|
||||||
|
"resource": {
|
||||||
|
"type": "orgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "read",
|
||||||
|
"resource": {
|
||||||
|
"type": "tasks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "write",
|
||||||
|
"resource": {
|
||||||
|
"type": "tasks",
|
||||||
|
"id": "09a64exxxxx",
|
||||||
|
"orgID": "b24955860xxxxx",
|
||||||
|
"org": "my-org"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You must copy the token field from the JSON response, and save it in your `scrutiny.yaml` config file. After that's
|
||||||
|
done, you can start the Scrutiny server
|
||||||
|
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ SCRUTINY_DEVICE_NAME - eg. /dev/sda
|
|||||||
SCRUTINY_DEVICE_TYPE - ATA/SCSI/NVMe
|
SCRUTINY_DEVICE_TYPE - ATA/SCSI/NVMe
|
||||||
SCRUTINY_DEVICE_SERIAL - eg. WDDJ324KSO
|
SCRUTINY_DEVICE_SERIAL - eg. WDDJ324KSO
|
||||||
SCRUTINY_MESSAGE - eg. "Scrutiny SMART error notification for device: %s\nFailure Type: %s\nDevice Name: %s\nDevice Serial: %s\nDevice Type: %s\nDate: %s"
|
SCRUTINY_MESSAGE - eg. "Scrutiny SMART error notification for device: %s\nFailure Type: %s\nDevice Name: %s\nDevice Serial: %s\nDevice Type: %s\nDate: %s"
|
||||||
|
SCRUTINY_HOST_ID - (optional) eg. "my-custom-host-id"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Operating systems without udev
|
||||||
|
|
||||||
|
Some operating systems do not come with `udev` out of the box, for example Alpine Linux. In these instances you will not be able to bind `/run/udev` to the container for sharing device metadata. Some operating systems offer `udev` as a package that can be installed separately, or an alternative (such as `eudev` in the case of Alpine Linux) that provides the same functionality.
|
||||||
|
|
||||||
|
To install `eudev` in Alpine Linux (run as root):
|
||||||
|
|
||||||
|
```
|
||||||
|
apk add eudev
|
||||||
|
setup-udev
|
||||||
|
```
|
||||||
|
|
||||||
|
Once your `udev` implementation is installed, create `/run/udev` with the following command:
|
||||||
|
|
||||||
|
```
|
||||||
|
udevadm trigger
|
||||||
|
```
|
||||||
|
|
||||||
|
On Alpine Linux, this also has the benefit of creating symlinks to device serial numbers in `/dev/disk/by-id`.
|
||||||
+70
-44
@@ -1,62 +1,88 @@
|
|||||||
|
|
||||||
// SQLite Table(s)
|
// SQLite Table(s)
|
||||||
Table device {
|
|
||||||
created_at timestamp
|
|
||||||
|
|
||||||
wwn varchar [pk]
|
Table Device {
|
||||||
|
//GORM attributes, see: http://gorm.io/docs/conventions.html
|
||||||
|
CreatedAt time
|
||||||
|
UpdatedAt time
|
||||||
|
DeletedAt time
|
||||||
|
|
||||||
//user provided
|
WWN string
|
||||||
label varchar
|
|
||||||
host_id varchar
|
|
||||||
|
|
||||||
// smartctl provided
|
DeviceName string
|
||||||
device_name varchar
|
DeviceUUID string
|
||||||
manufacturer varchar
|
DeviceSerialID string
|
||||||
model_name varchar
|
DeviceLabel string
|
||||||
interface_type varchar
|
|
||||||
interface_speed varchar
|
|
||||||
serial_number varchar
|
|
||||||
firmware varchar
|
|
||||||
rotational_speed varchar
|
|
||||||
capacity varchar
|
|
||||||
form_factor varchar
|
|
||||||
smart_support varchar
|
|
||||||
device_protocol varchar
|
|
||||||
device_type varchar
|
|
||||||
|
|
||||||
|
Manufacturer string
|
||||||
|
ModelName string
|
||||||
|
InterfaceType string
|
||||||
|
InterfaceSpeed string
|
||||||
|
SerialNumber string
|
||||||
|
Firmware string
|
||||||
|
RotationSpeed int
|
||||||
|
Capacity int64
|
||||||
|
FormFactor string
|
||||||
|
SmartSupport bool
|
||||||
|
DeviceProtocol string//protocol determines which smart attribute types are available (ATA, NVMe, SCSI)
|
||||||
|
DeviceType string//device type is used for querying with -d/t flag, should only be used by collector.
|
||||||
|
|
||||||
|
// User provided metadata
|
||||||
|
Label string
|
||||||
|
HostId string
|
||||||
|
|
||||||
|
// Data set by Scrutiny
|
||||||
|
DeviceStatus enum
|
||||||
|
}
|
||||||
|
|
||||||
|
Table Setting {
|
||||||
|
//GORM attributes, see: http://gorm.io/docs/conventions.html
|
||||||
|
|
||||||
|
SettingKeyName string
|
||||||
|
SettingKeyDescription string
|
||||||
|
SettingDataType string
|
||||||
|
|
||||||
|
SettingValueNumeric int64
|
||||||
|
SettingValueString string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// InfluxDB Tables
|
// InfluxDB Tables
|
||||||
Table device_temperature {
|
Table SmartTemperature {
|
||||||
//timestamp
|
Date time
|
||||||
created_at timestamp
|
DeviceWWN string //(tag)
|
||||||
|
Temp int64
|
||||||
//tags (indexed & queryable)
|
}
|
||||||
device_wwn varchar [pk]
|
|
||||||
|
|
||||||
//fields
|
|
||||||
temp bigint
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Table smart_ata_results {
|
Table Smart {
|
||||||
//timestamp
|
Date time
|
||||||
created_at timestamp
|
DeviceWWN string //(tag)
|
||||||
|
DeviceProtocol string
|
||||||
|
|
||||||
//tags (indexed & queryable)
|
//Metrics (fields)
|
||||||
device_wwn varchar [pk]
|
Temp int64
|
||||||
smart_status varchar
|
PowerOnHours int64
|
||||||
scrutiny_status varchar
|
PowerCycleCount int64
|
||||||
|
|
||||||
|
//Smart Status
|
||||||
|
Status enum
|
||||||
|
|
||||||
|
//SMART Attributes (fields)
|
||||||
//fields
|
Attr_ID_AttributeId int
|
||||||
temp bigint
|
Attr_ID_Value int64
|
||||||
power_on_hours bigint
|
Attr_ID_Threshold int64
|
||||||
power_cycle_count bigint
|
Attr_ID_Worst int64
|
||||||
|
Attr_ID_RawValue int64
|
||||||
|
Attr_ID_RawString string
|
||||||
|
Attr_ID_WhenFailed string
|
||||||
|
//Generated data
|
||||||
|
Attr_ID_TransformedValue int64
|
||||||
|
Attr_ID_Status enum
|
||||||
|
Attr_ID_StatusReason string
|
||||||
|
Attr_ID_FailureRate float64
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ref: device.wwn < smart_ata_results.device_wwn
|
Ref: Device.WWN < Smart.DeviceWWN
|
||||||
|
Ref: Device.WWN < SmartTemperature.DeviceWWN
|
||||||
|
|||||||
@@ -73,8 +73,6 @@ log:
|
|||||||
# - "join://shoutrrr:api-key@join/?devices=device1[,device2, ...][&icon=icon][&title=title]"
|
# - "join://shoutrrr:api-key@join/?devices=device1[,device2, ...][&icon=icon][&title=title]"
|
||||||
# - "script:///file/path/on/disk"
|
# - "script:///file/path/on/disk"
|
||||||
# - "https://www.example.com/path"
|
# - "https://www.example.com/path"
|
||||||
# filter_attributes: 'all' # options: 'all' or 'critical'
|
|
||||||
# level: 'fail' # options: 'fail', 'fail_scrutiny', 'fail_smart'
|
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
# FEATURES COMING SOON
|
# FEATURES COMING SOON
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/analogj/scrutiny
|
module github.com/analogj/scrutiny
|
||||||
|
|
||||||
go 1.17
|
go 1.18
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/analogj/go-util v0.0.0-20190301173314-5295e364eb14
|
github.com/analogj/go-util v0.0.0-20190301173314-5295e364eb14
|
||||||
@@ -13,6 +13,7 @@ require (
|
|||||||
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.2.2
|
||||||
|
github.com/samber/lo v1.25.0
|
||||||
github.com/sirupsen/logrus v1.4.2
|
github.com/sirupsen/logrus v1.4.2
|
||||||
github.com/spf13/viper v1.7.0
|
github.com/spf13/viper v1.7.0
|
||||||
github.com/stretchr/testify v1.7.1
|
github.com/stretchr/testify v1.7.1
|
||||||
@@ -23,7 +24,6 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect
|
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect
|
||||||
github.com/citilinkru/libudev v1.0.0 // indirect
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.0 // 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
|
||||||
@@ -68,6 +68,7 @@ require (
|
|||||||
github.com/subosito/gotenv v1.2.0 // indirect
|
github.com/subosito/gotenv v1.2.0 // 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.0.0-20201221181555-eec23a3978ad // 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.0.0-20210119194325-5f4716e94777 // indirect
|
||||||
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 // indirect
|
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 // indirect
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
|
||||||
@@ -77,7 +78,7 @@ require (
|
|||||||
gopkg.in/ini.v1 v1.55.0 // indirect
|
gopkg.in/ini.v1 v1.55.0 // indirect
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||||
gopkg.in/yaml.v2 v2.3.0 // indirect
|
gopkg.in/yaml.v2 v2.3.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||||
gosrc.io/xmpp v0.5.1 // indirect
|
gosrc.io/xmpp v0.5.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
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ github.com/chromedp/cdproto v0.0.0-20190812224334-39ef923dcb8d/go.mod h1:0YChpVz
|
|||||||
github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
|
github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
|
||||||
github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM=
|
github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM=
|
||||||
github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4=
|
github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4=
|
||||||
github.com/citilinkru/libudev v1.0.0 h1:upErSdhsJGdiKxwxPmvcz43fwJJD9R+y1j8BqU4wHog=
|
|
||||||
github.com/citilinkru/libudev v1.0.0/go.mod h1:yaNdhdtfJMs5flqeXzUOMO0mT9QnyNh/U/jdY4WhA/I=
|
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||||
github.com/containrrr/shoutrrr v0.4.4 h1:vHZ4E/76pKVY+Jyn/qhBz3X540Bn8NI5ppPHK4PyILY=
|
github.com/containrrr/shoutrrr v0.4.4 h1:vHZ4E/76pKVY+Jyn/qhBz3X540Bn8NI5ppPHK4PyILY=
|
||||||
@@ -282,12 +280,11 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
|
|||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kvz/logstreamer v0.0.0-20201023134116-02d20f4338f5 h1:dkCjlgGN81ahDFtM9R1x16gFGTa7ZvgZfdtAfM9lWOs=
|
github.com/kvz/logstreamer v0.0.0-20201023134116-02d20f4338f5 h1:dkCjlgGN81ahDFtM9R1x16gFGTa7ZvgZfdtAfM9lWOs=
|
||||||
github.com/kvz/logstreamer v0.0.0-20201023134116-02d20f4338f5/go.mod h1:8/LTPeDLaklcUjgSQBHbhBF1ibKAFxzS5o+H7USfMSA=
|
github.com/kvz/logstreamer v0.0.0-20201023134116-02d20f4338f5/go.mod h1:8/LTPeDLaklcUjgSQBHbhBF1ibKAFxzS5o+H7USfMSA=
|
||||||
github.com/labstack/echo/v4 v4.2.1/go.mod h1:AA49e0DZ8kk5jTOOCKNuPR6oTnBS0dYiM4FW1e6jwpg=
|
github.com/labstack/echo/v4 v4.2.1/go.mod h1:AA49e0DZ8kk5jTOOCKNuPR6oTnBS0dYiM4FW1e6jwpg=
|
||||||
@@ -345,6 +342,7 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
|
|||||||
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
|
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
|
||||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/nxadm/tail v1.4.6 h1:11TGpSHY7Esh/i/qnq02Jo5oVrI1Gue8Slbq0ujPZFQ=
|
github.com/nxadm/tail v1.4.6 h1:11TGpSHY7Esh/i/qnq02Jo5oVrI1Gue8Slbq0ujPZFQ=
|
||||||
github.com/nxadm/tail v1.4.6/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.6/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
@@ -388,6 +386,8 @@ github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThC
|
|||||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||||
|
github.com/samber/lo v1.25.0 h1:H8F6cB0RotRdgcRCivTByAQePaYhGMdOTJIj2QFS2I0=
|
||||||
|
github.com/samber/lo v1.25.0/go.mod h1:2I7tgIv8Q1SG2xEIkRq0F2i2zgxVpnyPOP0d3Gj2r+A=
|
||||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||||
@@ -436,10 +436,10 @@ github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMT
|
|||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||||
|
github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M=
|
||||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||||
github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A=
|
github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A=
|
||||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||||
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
|
|
||||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||||
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
|
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
|
||||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||||
@@ -485,6 +485,8 @@ golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL
|
|||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||||
|
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
|
||||||
|
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
|
||||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
@@ -656,8 +658,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
|||||||
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
|
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
|
||||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
|
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
|
||||||
@@ -675,8 +677,9 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/mysql v1.0.1 h1:omJoilUzyrAp0xNoio88lGJCroGdIOen9hq2A/+3ifw=
|
gorm.io/driver/mysql v1.0.1 h1:omJoilUzyrAp0xNoio88lGJCroGdIOen9hq2A/+3ifw=
|
||||||
gorm.io/driver/mysql v1.0.1/go.mod h1:KtqSthtg55lFp3S5kUXqlGaelnWpKitn4k1xZTnoiPw=
|
gorm.io/driver/mysql v1.0.1/go.mod h1:KtqSthtg55lFp3S5kUXqlGaelnWpKitn4k1xZTnoiPw=
|
||||||
gorm.io/driver/postgres v1.0.0 h1:Yh4jyFQ0a7F+JPU0Gtiam/eKmpT/XFc1FKxotGqc6FM=
|
gorm.io/driver/postgres v1.0.0 h1:Yh4jyFQ0a7F+JPU0Gtiam/eKmpT/XFc1FKxotGqc6FM=
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/config"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/config"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/errors"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/errors"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/version"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/version"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/web"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/web"
|
||||||
log "github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -107,7 +110,18 @@ OPTIONS:
|
|||||||
config.Set("log.file", c.String("log-file"))
|
config.Set("log.file", c.String("log-file"))
|
||||||
}
|
}
|
||||||
|
|
||||||
webServer := web.AppEngine{Config: config}
|
webLogger, logFile, err := CreateLogger(config)
|
||||||
|
if logFile != nil {
|
||||||
|
defer logFile.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsData, err := json.Marshal(config.AllSettings())
|
||||||
|
webLogger.Debug(string(settingsData), err)
|
||||||
|
|
||||||
|
webServer := web.AppEngine{Config: config, Logger: webLogger}
|
||||||
|
|
||||||
return webServer.Start()
|
return webServer.Start()
|
||||||
},
|
},
|
||||||
@@ -140,3 +154,27 @@ OPTIONS:
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateLogger(appConfig config.Interface) (*logrus.Entry, *os.File, error) {
|
||||||
|
logger := logrus.WithFields(logrus.Fields{
|
||||||
|
"type": "web",
|
||||||
|
})
|
||||||
|
//set default log level
|
||||||
|
if level, err := logrus.ParseLevel(appConfig.GetString("log.level")); err == nil {
|
||||||
|
logger.Logger.SetLevel(level)
|
||||||
|
} else {
|
||||||
|
logger.Logger.SetLevel(logrus.InfoLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var logFile *os.File
|
||||||
|
var err error
|
||||||
|
if appConfig.IsSet("log.file") && len(appConfig.GetString("log.file")) > 0 {
|
||||||
|
logFile, err = os.OpenFile(appConfig.GetString("log.file"), os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Errorf("Failed to open log file %s for output: %s", appConfig.GetString("log.file"), err)
|
||||||
|
return nil, logFile, err
|
||||||
|
}
|
||||||
|
logger.Logger.SetOutput(io.MultiWriter(os.Stderr, logFile))
|
||||||
|
}
|
||||||
|
return logger, logFile, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/analogj/go-util/utils"
|
"github.com/analogj/go-util/utils"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg"
|
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/errors"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/errors"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"log"
|
"log"
|
||||||
@@ -10,6 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const DB_USER_SETTINGS_SUBKEY = "user"
|
||||||
|
|
||||||
// When initializing this class the following methods must be called:
|
// When initializing this class the following methods must be called:
|
||||||
// Config.New
|
// Config.New
|
||||||
// Config.Init
|
// Config.Init
|
||||||
@@ -39,8 +40,6 @@ func (c *configuration) Init() error {
|
|||||||
c.SetDefault("log.file", "")
|
c.SetDefault("log.file", "")
|
||||||
|
|
||||||
c.SetDefault("notify.urls", []string{})
|
c.SetDefault("notify.urls", []string{})
|
||||||
c.SetDefault("notify.filter_attributes", pkg.NotifyFilterAttributesAll)
|
|
||||||
c.SetDefault("notify.level", pkg.NotifyLevelFail)
|
|
||||||
|
|
||||||
c.SetDefault("web.influxdb.scheme", "http")
|
c.SetDefault("web.influxdb.scheme", "http")
|
||||||
c.SetDefault("web.influxdb.host", "localhost")
|
c.SetDefault("web.influxdb.host", "localhost")
|
||||||
@@ -55,17 +54,6 @@ func (c *configuration) Init() error {
|
|||||||
//c.SetDefault("disks.include", []string{})
|
//c.SetDefault("disks.include", []string{})
|
||||||
//c.SetDefault("disks.exclude", []string{})
|
//c.SetDefault("disks.exclude", []string{})
|
||||||
|
|
||||||
//c.SetDefault("notify.metric.script", "/opt/scrutiny/config/notify-metrics.sh")
|
|
||||||
//c.SetDefault("notify.long.script", "/opt/scrutiny/config/notify-long-test.sh")
|
|
||||||
//c.SetDefault("notify.short.script", "/opt/scrutiny/config/notify-short-test.sh")
|
|
||||||
|
|
||||||
//c.SetDefault("collect.metric.enable", true)
|
|
||||||
//c.SetDefault("collect.metric.command", "-a -o on -S on")
|
|
||||||
//c.SetDefault("collect.long.enable", true)
|
|
||||||
//c.SetDefault("collect.long.command", "-a -o on -S on")
|
|
||||||
//c.SetDefault("collect.short.enable", true)
|
|
||||||
//c.SetDefault("collect.short.command", "-a -o on -S on")
|
|
||||||
|
|
||||||
//if you want to load a non-standard location system config file (~/drawbridge.yml), use ReadConfig
|
//if you want to load a non-standard location system config file (~/drawbridge.yml), use ReadConfig
|
||||||
c.SetConfigType("yaml")
|
c.SetConfigType("yaml")
|
||||||
//c.SetConfigName("drawbridge")
|
//c.SetConfigName("drawbridge")
|
||||||
@@ -77,7 +65,18 @@ func (c *configuration) Init() error {
|
|||||||
c.AutomaticEnv()
|
c.AutomaticEnv()
|
||||||
|
|
||||||
//CLI options will be added via the `Set()` function
|
//CLI options will be added via the `Set()` function
|
||||||
return nil
|
return c.ValidateConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *configuration) SubKeys(key string) []string {
|
||||||
|
return c.Sub(key).AllKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *configuration) Sub(key string) Interface {
|
||||||
|
config := configuration{
|
||||||
|
Viper: c.Viper.Sub(key),
|
||||||
|
}
|
||||||
|
return &config
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *configuration) ReadConfig(configFilePath string) error {
|
func (c *configuration) ReadConfig(configFilePath string) error {
|
||||||
@@ -120,24 +119,18 @@ func (c *configuration) ReadConfig(configFilePath string) error {
|
|||||||
// This function ensures that the merged config works correctly.
|
// This function ensures that the merged config works correctly.
|
||||||
func (c *configuration) ValidateConfig() error {
|
func (c *configuration) ValidateConfig() error {
|
||||||
|
|
||||||
////deserialize Questions
|
//the following keys are deprecated, and no longer supported
|
||||||
//questionsMap := map[string]Question{}
|
/*
|
||||||
//err := c.UnmarshalKey("questions", &questionsMap)
|
- notify.filter_attributes (replaced by metrics.status.filter_attributes SETTING)
|
||||||
//
|
- notify.level (replaced by metrics.notify.level and metrics.status.threshold SETTING)
|
||||||
//if err != nil {
|
*/
|
||||||
// log.Printf("questions could not be deserialized correctly. %v", err)
|
//TODO add docs and upgrade doc.
|
||||||
// return err
|
if c.IsSet("notify.filter_attributes") {
|
||||||
//}
|
return errors.ConfigValidationError("`notify.filter_attributes` configuration option is deprecated. Replaced by option in Dashboard Settings page")
|
||||||
//
|
}
|
||||||
//for _, v := range questionsMap {
|
if c.IsSet("notify.level") {
|
||||||
//
|
return errors.ConfigValidationError("`notify.level` configuration option is deprecated. Replaced by option in Dashboard Settings page")
|
||||||
// typeContent, ok := v.Schema["type"].(string)
|
}
|
||||||
// if !ok || len(typeContent) == 0 {
|
|
||||||
// return errors.QuestionSyntaxError("`type` is required for questions")
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_MergeConfigMap(t *testing.T) {
|
||||||
|
//setup
|
||||||
|
testConfig := configuration{
|
||||||
|
Viper: viper.New(),
|
||||||
|
}
|
||||||
|
testConfig.Set("user.dashboard_display", "hello")
|
||||||
|
testConfig.SetDefault("user.layout", "hello")
|
||||||
|
|
||||||
|
mergeSettings := map[string]interface{}{
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"dashboard_display": "dashboard_display",
|
||||||
|
"layout": "layout",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
//test
|
||||||
|
err := testConfig.MergeConfigMap(mergeSettings)
|
||||||
|
|
||||||
|
//verify
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// if using Set, the MergeConfigMap functionality will not override
|
||||||
|
// if using SetDefault, the MergeConfigMap will override correctly
|
||||||
|
require.Equal(t, "hello", testConfig.GetString("user.dashboard_display"))
|
||||||
|
require.Equal(t, "layout", testConfig.GetString("user.layout"))
|
||||||
|
|
||||||
|
}
|
||||||
@@ -12,12 +12,17 @@ type Interface interface {
|
|||||||
WriteConfig() error
|
WriteConfig() error
|
||||||
Set(key string, value interface{})
|
Set(key string, value interface{})
|
||||||
SetDefault(key string, value interface{})
|
SetDefault(key string, value interface{})
|
||||||
|
MergeConfigMap(cfg map[string]interface{}) error
|
||||||
|
|
||||||
|
Sub(key string) Interface
|
||||||
AllSettings() map[string]interface{}
|
AllSettings() map[string]interface{}
|
||||||
|
AllKeys() []string
|
||||||
|
SubKeys(key string) []string
|
||||||
IsSet(key string) bool
|
IsSet(key string) bool
|
||||||
Get(key string) interface{}
|
Get(key string) interface{}
|
||||||
GetBool(key string) bool
|
GetBool(key string) bool
|
||||||
GetInt(key string) int
|
GetInt(key string) int
|
||||||
|
GetInt64(key string) int64
|
||||||
GetString(key string) string
|
GetString(key string) string
|
||||||
GetStringSlice(key string) []string
|
GetStringSlice(key string) []string
|
||||||
UnmarshalKey(key string, rawVal interface{}, decoderOpts ...viper.DecoderConfigOption) error
|
UnmarshalKey(key string, rawVal interface{}, decoderOpts ...viper.DecoderConfigOption) error
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package mock_config
|
|||||||
import (
|
import (
|
||||||
reflect "reflect"
|
reflect "reflect"
|
||||||
|
|
||||||
|
config "github.com/analogj/scrutiny/webapp/backend/pkg/config"
|
||||||
gomock "github.com/golang/mock/gomock"
|
gomock "github.com/golang/mock/gomock"
|
||||||
viper "github.com/spf13/viper"
|
viper "github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@@ -34,6 +35,20 @@ func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
|
|||||||
return m.recorder
|
return m.recorder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AllKeys mocks base method.
|
||||||
|
func (m *MockInterface) AllKeys() []string {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "AllKeys")
|
||||||
|
ret0, _ := ret[0].([]string)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllKeys indicates an expected call of AllKeys.
|
||||||
|
func (mr *MockInterfaceMockRecorder) AllKeys() *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AllKeys", reflect.TypeOf((*MockInterface)(nil).AllKeys))
|
||||||
|
}
|
||||||
|
|
||||||
// AllSettings mocks base method.
|
// AllSettings mocks base method.
|
||||||
func (m *MockInterface) AllSettings() map[string]interface{} {
|
func (m *MockInterface) AllSettings() map[string]interface{} {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
@@ -90,6 +105,20 @@ func (mr *MockInterfaceMockRecorder) GetInt(key interface{}) *gomock.Call {
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInt", reflect.TypeOf((*MockInterface)(nil).GetInt), key)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInt", reflect.TypeOf((*MockInterface)(nil).GetInt), key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInt64 mocks base method.
|
||||||
|
func (m *MockInterface) GetInt64(key string) int64 {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetInt64", key)
|
||||||
|
ret0, _ := ret[0].(int64)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInt64 indicates an expected call of GetInt64.
|
||||||
|
func (mr *MockInterfaceMockRecorder) GetInt64(key interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInt64", reflect.TypeOf((*MockInterface)(nil).GetInt64), key)
|
||||||
|
}
|
||||||
|
|
||||||
// GetString mocks base method.
|
// GetString mocks base method.
|
||||||
func (m *MockInterface) GetString(key string) string {
|
func (m *MockInterface) GetString(key string) string {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
@@ -146,6 +175,20 @@ func (mr *MockInterfaceMockRecorder) IsSet(key interface{}) *gomock.Call {
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsSet", reflect.TypeOf((*MockInterface)(nil).IsSet), key)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsSet", reflect.TypeOf((*MockInterface)(nil).IsSet), key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MergeConfigMap mocks base method.
|
||||||
|
func (m *MockInterface) MergeConfigMap(cfg map[string]interface{}) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "MergeConfigMap", cfg)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeConfigMap indicates an expected call of MergeConfigMap.
|
||||||
|
func (mr *MockInterfaceMockRecorder) MergeConfigMap(cfg interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MergeConfigMap", reflect.TypeOf((*MockInterface)(nil).MergeConfigMap), cfg)
|
||||||
|
}
|
||||||
|
|
||||||
// ReadConfig mocks base method.
|
// ReadConfig mocks base method.
|
||||||
func (m *MockInterface) ReadConfig(configFilePath string) error {
|
func (m *MockInterface) ReadConfig(configFilePath string) error {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
@@ -184,6 +227,34 @@ func (mr *MockInterfaceMockRecorder) SetDefault(key, value interface{}) *gomock.
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDefault", reflect.TypeOf((*MockInterface)(nil).SetDefault), key, value)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDefault", reflect.TypeOf((*MockInterface)(nil).SetDefault), key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sub mocks base method.
|
||||||
|
func (m *MockInterface) Sub(key string) config.Interface {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "Sub", key)
|
||||||
|
ret0, _ := ret[0].(config.Interface)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub indicates an expected call of Sub.
|
||||||
|
func (mr *MockInterfaceMockRecorder) Sub(key interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sub", reflect.TypeOf((*MockInterface)(nil).Sub), key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubKeys mocks base method.
|
||||||
|
func (m *MockInterface) SubKeys(key string) []string {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "SubKeys", key)
|
||||||
|
ret0, _ := ret[0].([]string)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubKeys indicates an expected call of SubKeys.
|
||||||
|
func (mr *MockInterfaceMockRecorder) SubKeys(key interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubKeys", reflect.TypeOf((*MockInterface)(nil).SubKeys), key)
|
||||||
|
}
|
||||||
|
|
||||||
// UnmarshalKey mocks base method.
|
// UnmarshalKey mocks base method.
|
||||||
func (m *MockInterface) UnmarshalKey(key string, rawVal interface{}, decoderOpts ...viper.DecoderConfigOption) error {
|
func (m *MockInterface) UnmarshalKey(key string, rawVal interface{}, decoderOpts ...viper.DecoderConfigOption) error {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
|||||||
@@ -4,17 +4,11 @@ const DeviceProtocolAta = "ATA"
|
|||||||
const DeviceProtocolScsi = "SCSI"
|
const DeviceProtocolScsi = "SCSI"
|
||||||
const DeviceProtocolNvme = "NVMe"
|
const DeviceProtocolNvme = "NVMe"
|
||||||
|
|
||||||
const NotifyFilterAttributesAll = "all"
|
|
||||||
const NotifyFilterAttributesCritical = "critical"
|
|
||||||
|
|
||||||
const NotifyLevelFail = "fail"
|
|
||||||
const NotifyLevelFailScrutiny = "fail_scrutiny"
|
|
||||||
const NotifyLevelFailSmart = "fail_smart"
|
|
||||||
|
|
||||||
//go:generate stringer -type=AttributeStatus
|
//go:generate stringer -type=AttributeStatus
|
||||||
|
// AttributeStatus bitwise flag, 1,2,4,8,16,32,etc
|
||||||
type AttributeStatus uint8
|
type AttributeStatus uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// AttributeStatusPassed binary, 1,2,4,8,16,32,etc
|
|
||||||
AttributeStatusPassed AttributeStatus = 0
|
AttributeStatusPassed AttributeStatus = 0
|
||||||
AttributeStatusFailedSmart AttributeStatus = 1
|
AttributeStatusFailedSmart AttributeStatus = 1
|
||||||
AttributeStatusWarningScrutiny AttributeStatus = 2
|
AttributeStatusWarningScrutiny AttributeStatus = 2
|
||||||
@@ -30,9 +24,10 @@ func AttributeStatusToggle(b, flag AttributeStatus) AttributeStatus { return b ^
|
|||||||
func AttributeStatusHas(b, flag AttributeStatus) bool { return b&flag != 0 }
|
func AttributeStatusHas(b, flag AttributeStatus) bool { return b&flag != 0 }
|
||||||
|
|
||||||
//go:generate stringer -type=DeviceStatus
|
//go:generate stringer -type=DeviceStatus
|
||||||
|
// DeviceStatus bitwise flag, 1,2,4,8,16,32,etc
|
||||||
type DeviceStatus uint8
|
type DeviceStatus uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// DeviceStatusPassed binary, 1,2,4,8,16,32,etc
|
|
||||||
DeviceStatusPassed DeviceStatus = 0
|
DeviceStatusPassed DeviceStatus = 0
|
||||||
DeviceStatusFailedSmart DeviceStatus = 1
|
DeviceStatusFailedSmart DeviceStatus = 1
|
||||||
DeviceStatusFailedScrutiny DeviceStatus = 2
|
DeviceStatusFailedScrutiny DeviceStatus = 2
|
||||||
@@ -42,3 +37,29 @@ func DeviceStatusSet(b, flag DeviceStatus) DeviceStatus { return b | flag }
|
|||||||
func DeviceStatusClear(b, flag DeviceStatus) DeviceStatus { return b &^ flag }
|
func DeviceStatusClear(b, flag DeviceStatus) DeviceStatus { return b &^ flag }
|
||||||
func DeviceStatusToggle(b, flag DeviceStatus) DeviceStatus { return b ^ flag }
|
func DeviceStatusToggle(b, flag DeviceStatus) DeviceStatus { return b ^ flag }
|
||||||
func DeviceStatusHas(b, flag DeviceStatus) bool { return b&flag != 0 }
|
func DeviceStatusHas(b, flag DeviceStatus) bool { return b&flag != 0 }
|
||||||
|
|
||||||
|
// Metrics Specific Filtering & Threshold Constants
|
||||||
|
type MetricsNotifyLevel int64
|
||||||
|
|
||||||
|
const (
|
||||||
|
MetricsNotifyLevelWarn MetricsNotifyLevel = 1
|
||||||
|
MetricsNotifyLevelFail MetricsNotifyLevel = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetricsStatusFilterAttributes int64
|
||||||
|
|
||||||
|
const (
|
||||||
|
MetricsStatusFilterAttributesAll MetricsStatusFilterAttributes = 0
|
||||||
|
MetricsStatusFilterAttributesCritical MetricsStatusFilterAttributes = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetricsStatusThreshold bitwise flag, 1,2,4,8,16,32,etc
|
||||||
|
type MetricsStatusThreshold int64
|
||||||
|
|
||||||
|
const (
|
||||||
|
MetricsStatusThresholdSmart MetricsStatusThreshold = 1
|
||||||
|
MetricsStatusThresholdScrutiny MetricsStatusThreshold = 2
|
||||||
|
|
||||||
|
//shortcut
|
||||||
|
MetricsStatusThresholdBoth MetricsStatusThreshold = 3
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ import (
|
|||||||
type DeviceRepo interface {
|
type DeviceRepo interface {
|
||||||
Close() error
|
Close() error
|
||||||
|
|
||||||
//GetSettings()
|
|
||||||
//SaveSetting()
|
|
||||||
|
|
||||||
RegisterDevice(ctx context.Context, dev models.Device) error
|
RegisterDevice(ctx context.Context, dev models.Device) error
|
||||||
GetDevices(ctx context.Context) ([]models.Device, error)
|
GetDevices(ctx context.Context) ([]models.Device, error)
|
||||||
UpdateDevice(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (models.Device, error)
|
UpdateDevice(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (models.Device, error)
|
||||||
@@ -28,4 +25,7 @@ type DeviceRepo interface {
|
|||||||
|
|
||||||
GetSummary(ctx context.Context) (map[string]*models.DeviceSummary, error)
|
GetSummary(ctx context.Context) (map[string]*models.DeviceSummary, error)
|
||||||
GetSmartTemperatureHistory(ctx context.Context, durationKey string) (map[string][]measurements.SmartTemperature, error)
|
GetSmartTemperatureHistory(ctx context.Context, durationKey string) (map[string][]measurements.SmartTemperature, error)
|
||||||
|
|
||||||
|
LoadSettings(ctx context.Context) (*models.Settings, error)
|
||||||
|
SaveSettings(ctx context.Context, settings models.Settings) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package m20220716214900
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Setting struct {
|
||||||
|
//GORM attributes, see: http://gorm.io/docs/conventions.html
|
||||||
|
gorm.Model
|
||||||
|
|
||||||
|
SettingKeyName string `json:"setting_key_name"`
|
||||||
|
SettingKeyDescription string `json:"setting_key_description"`
|
||||||
|
SettingDataType string `json:"setting_data_type"`
|
||||||
|
|
||||||
|
SettingValueNumeric int `json:"setting_value_numeric"`
|
||||||
|
SettingValueString string `json:"setting_value_string"`
|
||||||
|
SettingValueBool bool `json:"setting_value_bool"`
|
||||||
|
}
|
||||||
@@ -62,7 +62,20 @@ func NewScrutinyRepository(appConfig config.Interface, globalLogger logrus.Field
|
|||||||
// Gorm/SQLite setup
|
// Gorm/SQLite setup
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
globalLogger.Infof("Trying to connect to scrutiny sqlite db: %s\n", appConfig.GetString("web.database.location"))
|
globalLogger.Infof("Trying to connect to scrutiny sqlite db: %s\n", appConfig.GetString("web.database.location"))
|
||||||
database, err := gorm.Open(sqlite.Open(appConfig.GetString("web.database.location")), &gorm.Config{
|
|
||||||
|
// When a transaction cannot lock the database, because it is already locked by another one,
|
||||||
|
// SQLite by default throws an error: database is locked. This behavior is usually not appropriate when
|
||||||
|
// concurrent access is needed, typically when multiple processes write to the same database.
|
||||||
|
// PRAGMA busy_timeout lets you set a timeout or a handler for these events. When setting a timeout,
|
||||||
|
// SQLite will try the transaction multiple times within this timeout.
|
||||||
|
// fixes #341
|
||||||
|
// https://rsqlite.r-dbi.org/reference/sqlitesetbusyhandler
|
||||||
|
// retrying for 30000 milliseconds, 30seconds - this would be unreasonable for a distributed multi-tenant application,
|
||||||
|
// but should be fine for local usage.
|
||||||
|
pragmaStr := sqlitePragmaString(map[string]string{
|
||||||
|
"busy_timeout": "30000",
|
||||||
|
})
|
||||||
|
database, err := gorm.Open(sqlite.Open(appConfig.GetString("web.database.location")+pragmaStr), &gorm.Config{
|
||||||
//TODO: figure out how to log database queries again.
|
//TODO: figure out how to log database queries again.
|
||||||
//Logger: logger
|
//Logger: logger
|
||||||
DisableForeignKeyConstraintWhenMigrating: true,
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
@@ -242,21 +255,29 @@ func (sr *scrutinyRepository) EnsureBuckets(ctx context.Context, org *domain.Org
|
|||||||
|
|
||||||
//create buckets (used for downsampling)
|
//create buckets (used for downsampling)
|
||||||
weeklyBucket := fmt.Sprintf("%s_weekly", sr.appConfig.GetString("web.influxdb.bucket"))
|
weeklyBucket := fmt.Sprintf("%s_weekly", sr.appConfig.GetString("web.influxdb.bucket"))
|
||||||
if _, foundErr := sr.influxClient.BucketsAPI().FindBucketByName(ctx, weeklyBucket); foundErr != nil {
|
if foundWeeklyBucket, foundErr := sr.influxClient.BucketsAPI().FindBucketByName(ctx, weeklyBucket); foundErr != nil {
|
||||||
// metrics_weekly bucket will have a retention period of 8+1 weeks (since it will be down-sampled once a month)
|
// metrics_weekly bucket will have a retention period of 8+1 weeks (since it will be down-sampled once a month)
|
||||||
_, err := sr.influxClient.BucketsAPI().CreateBucketWithName(ctx, org, weeklyBucket, weeklyBucketRetentionRule)
|
_, err := sr.influxClient.BucketsAPI().CreateBucketWithName(ctx, org, weeklyBucket, weeklyBucketRetentionRule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if sr.appConfig.GetBool("web.influxdb.retention_policy") {
|
||||||
|
//correctly set the retention period for the bucket (may not be able to do it during setup/creation)
|
||||||
|
foundWeeklyBucket.RetentionRules = domain.RetentionRules{weeklyBucketRetentionRule}
|
||||||
|
sr.influxClient.BucketsAPI().UpdateBucket(ctx, foundWeeklyBucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
monthlyBucket := fmt.Sprintf("%s_monthly", sr.appConfig.GetString("web.influxdb.bucket"))
|
monthlyBucket := fmt.Sprintf("%s_monthly", sr.appConfig.GetString("web.influxdb.bucket"))
|
||||||
if _, foundErr := sr.influxClient.BucketsAPI().FindBucketByName(ctx, monthlyBucket); foundErr != nil {
|
if foundMonthlyBucket, foundErr := sr.influxClient.BucketsAPI().FindBucketByName(ctx, monthlyBucket); foundErr != nil {
|
||||||
// metrics_monthly bucket will have a retention period of 24+1 months (since it will be down-sampled once a year)
|
// metrics_monthly bucket will have a retention period of 24+1 months (since it will be down-sampled once a year)
|
||||||
_, err := sr.influxClient.BucketsAPI().CreateBucketWithName(ctx, org, monthlyBucket, monthlyBucketRetentionRule)
|
_, err := sr.influxClient.BucketsAPI().CreateBucketWithName(ctx, org, monthlyBucket, monthlyBucketRetentionRule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if sr.appConfig.GetBool("web.influxdb.retention_policy") {
|
||||||
|
//correctly set the retention period for the bucket (may not be able to do it during setup/creation)
|
||||||
|
foundMonthlyBucket.RetentionRules = domain.RetentionRules{monthlyBucketRetentionRule}
|
||||||
|
sr.influxClient.BucketsAPI().UpdateBucket(ctx, foundMonthlyBucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
yearlyBucket := fmt.Sprintf("%s_yearly", sr.appConfig.GetString("web.influxdb.bucket"))
|
yearlyBucket := fmt.Sprintf("%s_yearly", sr.appConfig.GetString("web.influxdb.bucket"))
|
||||||
@@ -442,3 +463,16 @@ func (sr *scrutinyRepository) lookupNestedDurationKeys(durationKey string) []str
|
|||||||
}
|
}
|
||||||
return []string{DURATION_KEY_WEEK}
|
return []string{DURATION_KEY_WEEK}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sqlitePragmaString(pragmas map[string]string) string {
|
||||||
|
q := url.Values{}
|
||||||
|
for key, val := range pragmas {
|
||||||
|
q.Add("_pragma", key+"="+val)
|
||||||
|
}
|
||||||
|
|
||||||
|
queryStr := q.Encode()
|
||||||
|
if len(queryStr) > 0 {
|
||||||
|
return "?" + queryStr
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/analogj/scrutiny/webapp/backend/pkg"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20201107210306"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20201107210306"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220503120000"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220503120000"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220509170100"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220509170100"
|
||||||
|
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220716214900"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
|
||||||
@@ -267,6 +269,85 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
|
|||||||
return tx.AutoMigrate(m20220509170100.Device{})
|
return tx.AutoMigrate(m20220509170100.Device{})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "m20220709181300",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
|
||||||
|
// delete devices with empty `wwn` field (they are impossible to delete manually), and are invalid.
|
||||||
|
return tx.Where("wwn = ?", "").Delete(&models.Device{}).Error
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "m20220716214900", // add settings table.
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
|
||||||
|
// adding the settings table.
|
||||||
|
err := tx.AutoMigrate(m20220716214900.Setting{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
//add defaults.
|
||||||
|
|
||||||
|
var defaultSettings = []m20220716214900.Setting{
|
||||||
|
{
|
||||||
|
SettingKeyName: "theme",
|
||||||
|
SettingKeyDescription: "Frontend theme ('light' | 'dark' | 'system')",
|
||||||
|
SettingDataType: "string",
|
||||||
|
SettingValueString: "system", // options: 'light' | 'dark' | 'system'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SettingKeyName: "layout",
|
||||||
|
SettingKeyDescription: "Frontend layout ('material')",
|
||||||
|
SettingDataType: "string",
|
||||||
|
SettingValueString: "material",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SettingKeyName: "dashboard_display",
|
||||||
|
SettingKeyDescription: "Frontend device display title ('name' | 'serial_id' | 'uuid' | 'label')",
|
||||||
|
SettingDataType: "string",
|
||||||
|
SettingValueString: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SettingKeyName: "dashboard_sort",
|
||||||
|
SettingKeyDescription: "Frontend device sort by ('status' | 'title' | 'age')",
|
||||||
|
SettingDataType: "string",
|
||||||
|
SettingValueString: "status",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SettingKeyName: "temperature_unit",
|
||||||
|
SettingKeyDescription: "Frontend temperature unit ('celsius' | 'fahrenheit')",
|
||||||
|
SettingDataType: "string",
|
||||||
|
SettingValueString: "celsius",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SettingKeyName: "file_size_si_units",
|
||||||
|
SettingKeyDescription: "File size in SI units (true | false)",
|
||||||
|
SettingDataType: "bool",
|
||||||
|
SettingValueBool: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
SettingKeyName: "metrics.notify_level",
|
||||||
|
SettingKeyDescription: "Determines which device status will cause a notification (fail or warn)",
|
||||||
|
SettingDataType: "numeric",
|
||||||
|
SettingValueNumeric: int(pkg.MetricsNotifyLevelFail), // options: 'fail' or 'warn'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SettingKeyName: "metrics.status_filter_attributes",
|
||||||
|
SettingKeyDescription: "Determines which attributes should impact device status",
|
||||||
|
SettingDataType: "numeric",
|
||||||
|
SettingValueNumeric: int(pkg.MetricsStatusFilterAttributesAll), // options: 'all' or 'critical'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SettingKeyName: "metrics.status_threshold",
|
||||||
|
SettingKeyDescription: "Determines which threshold should impact device status",
|
||||||
|
SettingDataType: "numeric",
|
||||||
|
SettingValueNumeric: int(pkg.MetricsStatusThresholdBoth), // options: 'scrutiny', 'smart', 'both'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return tx.Create(&defaultSettings).Error
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := m.Migrate(); err != nil {
|
if err := m.Migrate(); err != nil {
|
||||||
@@ -274,6 +355,30 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
sr.logger.Infoln("Database migration completed successfully")
|
sr.logger.Infoln("Database migration completed successfully")
|
||||||
|
|
||||||
|
//these migrations cannot be done within a transaction, so they are done as a separate group, with `UseTransaction = false`
|
||||||
|
sr.logger.Infoln("SQLite global configuration migrations starting. Please wait....")
|
||||||
|
globalMigrateOptions := gormigrate.DefaultOptions
|
||||||
|
globalMigrateOptions.UseTransaction = false
|
||||||
|
gm := gormigrate.New(sr.gormClient, globalMigrateOptions, []*gormigrate.Migration{
|
||||||
|
{
|
||||||
|
ID: "g20220802211500",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
//shrink the Database (maybe necessary after 20220503113100)
|
||||||
|
if err := tx.Exec("VACUUM;").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := gm.Migrate(); err != nil {
|
||||||
|
sr.logger.Errorf("SQLite global configuration migrations failed with error. \n Please open a github issue at https://github.com/AnalogJ/scrutiny and attach a copy of your scrutiny.db file. \n %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sr.logger.Infoln("SQLite global configuration migrations completed successfully")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/analogj/scrutiny/webapp/backend/pkg/config"
|
||||||
|
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
|
||||||
|
"github.com/mitchellh/mapstructure"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadSettings will retrieve settings from the database, store them in the AppConfig object, and return a Settings struct
|
||||||
|
func (sr *scrutinyRepository) LoadSettings(ctx context.Context) (*models.Settings, error) {
|
||||||
|
settingsEntries := []models.SettingEntry{}
|
||||||
|
if err := sr.gormClient.WithContext(ctx).Find(&settingsEntries).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("Could not get settings from DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// store retrieved settings in the AppConfig obj
|
||||||
|
for _, settingsEntry := range settingsEntries {
|
||||||
|
configKey := fmt.Sprintf("%s.%s", config.DB_USER_SETTINGS_SUBKEY, settingsEntry.SettingKeyName)
|
||||||
|
|
||||||
|
if settingsEntry.SettingDataType == "numeric" {
|
||||||
|
sr.appConfig.SetDefault(configKey, settingsEntry.SettingValueNumeric)
|
||||||
|
} else if settingsEntry.SettingDataType == "string" {
|
||||||
|
sr.appConfig.SetDefault(configKey, settingsEntry.SettingValueString)
|
||||||
|
} else if settingsEntry.SettingDataType == "bool" {
|
||||||
|
sr.appConfig.SetDefault(configKey, settingsEntry.SettingValueBool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshal the dbsetting object data to a settings object.
|
||||||
|
var settings models.Settings
|
||||||
|
err := sr.appConfig.UnmarshalKey(config.DB_USER_SETTINGS_SUBKEY, &settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// testing
|
||||||
|
// curl -d '{"metrics": { "notify_level": 5, "status_filter_attributes": 5, "status_threshold": 5 }}' -H "Content-Type: application/json" -X POST http://localhost:9090/api/settings
|
||||||
|
// SaveSettings will update settings in AppConfig object, then save the settings to the database.
|
||||||
|
func (sr *scrutinyRepository) SaveSettings(ctx context.Context, settings models.Settings) error {
|
||||||
|
//save the entries to the appconfig
|
||||||
|
settingsMap := &map[string]interface{}{}
|
||||||
|
err := mapstructure.Decode(settings, &settingsMap)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settingsWrapperMap := map[string]interface{}{}
|
||||||
|
settingsWrapperMap[config.DB_USER_SETTINGS_SUBKEY] = *settingsMap
|
||||||
|
err = sr.appConfig.MergeConfigMap(settingsWrapperMap)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sr.logger.Debugf("after merge settings: %v", sr.appConfig.AllSettings())
|
||||||
|
//retrieve current settings from the database
|
||||||
|
settingsEntries := []models.SettingEntry{}
|
||||||
|
if err := sr.gormClient.WithContext(ctx).Find(&settingsEntries).Error; err != nil {
|
||||||
|
return fmt.Errorf("Could not get settings from DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//update settingsEntries
|
||||||
|
for ndx, settingsEntry := range settingsEntries {
|
||||||
|
configKey := fmt.Sprintf("%s.%s", config.DB_USER_SETTINGS_SUBKEY, strings.ToLower(settingsEntry.SettingKeyName))
|
||||||
|
|
||||||
|
if settingsEntry.SettingDataType == "numeric" {
|
||||||
|
settingsEntries[ndx].SettingValueNumeric = sr.appConfig.GetInt(configKey)
|
||||||
|
} else if settingsEntry.SettingDataType == "string" {
|
||||||
|
settingsEntries[ndx].SettingValueString = sr.appConfig.GetString(configKey)
|
||||||
|
} else if settingsEntry.SettingDataType == "bool" {
|
||||||
|
settingsEntries[ndx].SettingValueBool = sr.appConfig.GetBool(configKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// store in database.
|
||||||
|
//TODO: this should be `sr.gormClient.Updates(&settingsEntries).Error`
|
||||||
|
err := sr.gormClient.Model(&models.SettingEntry{}).Where([]uint{settingsEntry.ID}).Select("setting_value_numeric", "setting_value_string", "setting_value_bool").Updates(settingsEntries[ndx]).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -11,35 +11,71 @@ import (
|
|||||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
func (sr *scrutinyRepository) EnsureTasks(ctx context.Context, orgID string) error {
|
func (sr *scrutinyRepository) EnsureTasks(ctx context.Context, orgID string) error {
|
||||||
weeklyTaskName := "tsk-weekly-aggr"
|
weeklyTaskName := "tsk-weekly-aggr"
|
||||||
|
weeklyTaskScript := sr.DownsampleScript("weekly", weeklyTaskName, "0 1 * * 0")
|
||||||
if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: weeklyTaskName}); findErr == nil && len(found) == 0 {
|
if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: weeklyTaskName}); findErr == nil && len(found) == 0 {
|
||||||
//weekly on Sunday at 1:00am
|
//weekly on Sunday at 1:00am
|
||||||
_, err := sr.influxTaskApi.CreateTaskWithCron(ctx, weeklyTaskName, sr.DownsampleScript("weekly"), "0 1 * * 0", orgID)
|
_, err := sr.influxTaskApi.CreateTaskByFlux(ctx, weeklyTaskScript, orgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if len(found) == 1 {
|
||||||
|
//check if we should update
|
||||||
|
task := &found[0]
|
||||||
|
if weeklyTaskScript != task.Flux {
|
||||||
|
sr.logger.Infoln("updating weekly task script")
|
||||||
|
task.Flux = weeklyTaskScript
|
||||||
|
_, err := sr.influxTaskApi.UpdateTask(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
monthlyTaskName := "tsk-monthly-aggr"
|
monthlyTaskName := "tsk-monthly-aggr"
|
||||||
|
monthlyTaskScript := sr.DownsampleScript("monthly", monthlyTaskName, "30 1 1 * *")
|
||||||
if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: monthlyTaskName}); findErr == nil && len(found) == 0 {
|
if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: monthlyTaskName}); findErr == nil && len(found) == 0 {
|
||||||
//monthly on first day of the month at 1:30am
|
//monthly on first day of the month at 1:30am
|
||||||
_, err := sr.influxTaskApi.CreateTaskWithCron(ctx, monthlyTaskName, sr.DownsampleScript("monthly"), "30 1 1 * *", orgID)
|
_, err := sr.influxTaskApi.CreateTaskByFlux(ctx, monthlyTaskScript, orgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if len(found) == 1 {
|
||||||
|
//check if we should update
|
||||||
|
task := &found[0]
|
||||||
|
if monthlyTaskScript != task.Flux {
|
||||||
|
sr.logger.Infoln("updating monthly task script")
|
||||||
|
task.Flux = monthlyTaskScript
|
||||||
|
_, err := sr.influxTaskApi.UpdateTask(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
yearlyTaskName := "tsk-yearly-aggr"
|
yearlyTaskName := "tsk-yearly-aggr"
|
||||||
|
yearlyTaskScript := sr.DownsampleScript("yearly", yearlyTaskName, "0 2 1 1 *")
|
||||||
if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: yearlyTaskName}); findErr == nil && len(found) == 0 {
|
if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: yearlyTaskName}); findErr == nil && len(found) == 0 {
|
||||||
//yearly on the first day of the year at 2:00am
|
//yearly on the first day of the year at 2:00am
|
||||||
_, err := sr.influxTaskApi.CreateTaskWithCron(ctx, yearlyTaskName, sr.DownsampleScript("yearly"), "0 2 1 1 *", orgID)
|
_, err := sr.influxTaskApi.CreateTaskByFlux(ctx, yearlyTaskScript, orgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if len(found) == 1 {
|
||||||
|
//check if we should update
|
||||||
|
task := &found[0]
|
||||||
|
if yearlyTaskScript != task.Flux {
|
||||||
|
sr.logger.Infoln("updating yearly task script")
|
||||||
|
task.Flux = yearlyTaskScript
|
||||||
|
_, err := sr.influxTaskApi.UpdateTask(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sr *scrutinyRepository) DownsampleScript(aggregationType string) string {
|
func (sr *scrutinyRepository) DownsampleScript(aggregationType string, name string, cron string) string {
|
||||||
var sourceBucket string // the source of the data
|
var sourceBucket string // the source of the data
|
||||||
var destBucket string // the destination for the aggregated data
|
var destBucket string // the destination for the aggregated data
|
||||||
var rangeStart string
|
var rangeStart string
|
||||||
@@ -88,30 +124,37 @@ func (sr *scrutinyRepository) DownsampleScript(aggregationType string) string {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
return fmt.Sprintf(`
|
||||||
sourceBucket = "%s"
|
option task = {
|
||||||
rangeStart = %s
|
name: "%s",
|
||||||
rangeEnd = %s
|
cron: "%s",
|
||||||
aggWindow = %s
|
}
|
||||||
destBucket = "%s"
|
|
||||||
destOrg = "%s"
|
|
||||||
|
|
||||||
from(bucket: sourceBucket)
|
sourceBucket = "%s"
|
||||||
|> range(start: rangeStart, stop: rangeEnd)
|
rangeStart = %s
|
||||||
|> filter(fn: (r) => r["_measurement"] == "smart" )
|
rangeEnd = %s
|
||||||
|> group(columns: ["device_wwn", "_field"])
|
aggWindow = %s
|
||||||
|> aggregateWindow(every: aggWindow, fn: last, createEmpty: false)
|
destBucket = "%s"
|
||||||
|> to(bucket: destBucket, org: destOrg)
|
destOrg = "%s"
|
||||||
|
|
||||||
temp_data = from(bucket: sourceBucket)
|
from(bucket: sourceBucket)
|
||||||
|> range(start: rangeStart, stop: rangeEnd)
|
|> range(start: rangeStart, stop: rangeEnd)
|
||||||
|> filter(fn: (r) => r["_measurement"] == "temp")
|
|> filter(fn: (r) => r["_measurement"] == "smart" )
|
||||||
|> group(columns: ["device_wwn"])
|
|> group(columns: ["device_wwn", "_field"])
|
||||||
|> toInt()
|
|> aggregateWindow(every: aggWindow, fn: last, createEmpty: false)
|
||||||
|
|> to(bucket: destBucket, org: destOrg)
|
||||||
|
|
||||||
temp_data
|
from(bucket: sourceBucket)
|
||||||
|> aggregateWindow(fn: mean, every: aggWindow, createEmpty: false)
|
|> range(start: rangeStart, stop: rangeEnd)
|
||||||
|> to(bucket: destBucket, org: destOrg)
|
|> filter(fn: (r) => r["_measurement"] == "temp")
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|> aggregateWindow(fn: mean, every: aggWindow, createEmpty: false)
|
||||||
|
|> set(key: "_measurement", value: "temp")
|
||||||
|
|> set(key: "_field", value: "temp")
|
||||||
|
|> to(bucket: destBucket, org: destOrg)
|
||||||
`,
|
`,
|
||||||
|
name,
|
||||||
|
cron,
|
||||||
sourceBucket,
|
sourceBucket,
|
||||||
rangeStart,
|
rangeStart,
|
||||||
rangeEnd,
|
rangeEnd,
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock"
|
||||||
|
"github.com/golang/mock/gomock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_DownsampleScript_Weekly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//setup
|
||||||
|
mockCtrl := gomock.NewController(t)
|
||||||
|
defer mockCtrl.Finish()
|
||||||
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
|
||||||
|
|
||||||
|
deviceRepo := scrutinyRepository{
|
||||||
|
appConfig: fakeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregationType := "weekly"
|
||||||
|
|
||||||
|
//test
|
||||||
|
influxDbScript := deviceRepo.DownsampleScript(aggregationType, "tsk-weekly-aggr", "0 1 * * 0")
|
||||||
|
|
||||||
|
//assert
|
||||||
|
require.Equal(t, `
|
||||||
|
option task = {
|
||||||
|
name: "tsk-weekly-aggr",
|
||||||
|
cron: "0 1 * * 0",
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceBucket = "metrics"
|
||||||
|
rangeStart = -2w
|
||||||
|
rangeEnd = -1w
|
||||||
|
aggWindow = 1w
|
||||||
|
destBucket = "metrics_weekly"
|
||||||
|
destOrg = "scrutiny"
|
||||||
|
|
||||||
|
from(bucket: sourceBucket)
|
||||||
|
|> range(start: rangeStart, stop: rangeEnd)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "smart" )
|
||||||
|
|> group(columns: ["device_wwn", "_field"])
|
||||||
|
|> aggregateWindow(every: aggWindow, fn: last, createEmpty: false)
|
||||||
|
|> to(bucket: destBucket, org: destOrg)
|
||||||
|
|
||||||
|
from(bucket: sourceBucket)
|
||||||
|
|> range(start: rangeStart, stop: rangeEnd)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp")
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|> aggregateWindow(fn: mean, every: aggWindow, createEmpty: false)
|
||||||
|
|> set(key: "_measurement", value: "temp")
|
||||||
|
|> set(key: "_field", value: "temp")
|
||||||
|
|> to(bucket: destBucket, org: destOrg)
|
||||||
|
`, influxDbScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_DownsampleScript_Monthly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//setup
|
||||||
|
mockCtrl := gomock.NewController(t)
|
||||||
|
defer mockCtrl.Finish()
|
||||||
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
|
||||||
|
|
||||||
|
deviceRepo := scrutinyRepository{
|
||||||
|
appConfig: fakeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregationType := "monthly"
|
||||||
|
|
||||||
|
//test
|
||||||
|
influxDbScript := deviceRepo.DownsampleScript(aggregationType, "tsk-monthly-aggr", "30 1 1 * *")
|
||||||
|
|
||||||
|
//assert
|
||||||
|
require.Equal(t, `
|
||||||
|
option task = {
|
||||||
|
name: "tsk-monthly-aggr",
|
||||||
|
cron: "30 1 1 * *",
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceBucket = "metrics_weekly"
|
||||||
|
rangeStart = -2mo
|
||||||
|
rangeEnd = -1mo
|
||||||
|
aggWindow = 1mo
|
||||||
|
destBucket = "metrics_monthly"
|
||||||
|
destOrg = "scrutiny"
|
||||||
|
|
||||||
|
from(bucket: sourceBucket)
|
||||||
|
|> range(start: rangeStart, stop: rangeEnd)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "smart" )
|
||||||
|
|> group(columns: ["device_wwn", "_field"])
|
||||||
|
|> aggregateWindow(every: aggWindow, fn: last, createEmpty: false)
|
||||||
|
|> to(bucket: destBucket, org: destOrg)
|
||||||
|
|
||||||
|
from(bucket: sourceBucket)
|
||||||
|
|> range(start: rangeStart, stop: rangeEnd)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp")
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|> aggregateWindow(fn: mean, every: aggWindow, createEmpty: false)
|
||||||
|
|> set(key: "_measurement", value: "temp")
|
||||||
|
|> set(key: "_field", value: "temp")
|
||||||
|
|> to(bucket: destBucket, org: destOrg)
|
||||||
|
`, influxDbScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_DownsampleScript_Yearly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//setup
|
||||||
|
mockCtrl := gomock.NewController(t)
|
||||||
|
defer mockCtrl.Finish()
|
||||||
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
|
||||||
|
|
||||||
|
deviceRepo := scrutinyRepository{
|
||||||
|
appConfig: fakeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregationType := "yearly"
|
||||||
|
|
||||||
|
//test
|
||||||
|
influxDbScript := deviceRepo.DownsampleScript(aggregationType, "tsk-yearly-aggr", "0 2 1 1 *")
|
||||||
|
|
||||||
|
//assert
|
||||||
|
require.Equal(t, `
|
||||||
|
option task = {
|
||||||
|
name: "tsk-yearly-aggr",
|
||||||
|
cron: "0 2 1 1 *",
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceBucket = "metrics_monthly"
|
||||||
|
rangeStart = -2y
|
||||||
|
rangeEnd = -1y
|
||||||
|
aggWindow = 1y
|
||||||
|
destBucket = "metrics_yearly"
|
||||||
|
destOrg = "scrutiny"
|
||||||
|
|
||||||
|
from(bucket: sourceBucket)
|
||||||
|
|> range(start: rangeStart, stop: rangeEnd)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "smart" )
|
||||||
|
|> group(columns: ["device_wwn", "_field"])
|
||||||
|
|> aggregateWindow(every: aggWindow, fn: last, createEmpty: false)
|
||||||
|
|> to(bucket: destBucket, org: destOrg)
|
||||||
|
|
||||||
|
from(bucket: sourceBucket)
|
||||||
|
|> range(start: rangeStart, stop: rangeEnd)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp")
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|> aggregateWindow(fn: mean, every: aggWindow, createEmpty: false)
|
||||||
|
|> set(key: "_measurement", value: "temp")
|
||||||
|
|> set(key: "_field", value: "temp")
|
||||||
|
|> to(bucket: destBucket, org: destOrg)
|
||||||
|
`, influxDbScript)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock"
|
||||||
|
"github.com/golang/mock/gomock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_aggregateTempQuery_Week(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//setup
|
||||||
|
mockCtrl := gomock.NewController(t)
|
||||||
|
defer mockCtrl.Finish()
|
||||||
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
|
||||||
|
|
||||||
|
deviceRepo := scrutinyRepository{
|
||||||
|
appConfig: fakeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregationType := DURATION_KEY_WEEK
|
||||||
|
|
||||||
|
//test
|
||||||
|
influxDbScript := deviceRepo.aggregateTempQuery(aggregationType)
|
||||||
|
|
||||||
|
//assert
|
||||||
|
require.Equal(t, `import "influxdata/influxdb/schema"
|
||||||
|
weekData = from(bucket: "metrics")
|
||||||
|
|> range(start: -1w, stop: now())
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp" )
|
||||||
|
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|
||||||
|
weekData
|
||||||
|
|> schema.fieldsAsCols()
|
||||||
|
|> yield()`, influxDbScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_aggregateTempQuery_Month(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//setup
|
||||||
|
mockCtrl := gomock.NewController(t)
|
||||||
|
defer mockCtrl.Finish()
|
||||||
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
|
||||||
|
|
||||||
|
deviceRepo := scrutinyRepository{
|
||||||
|
appConfig: fakeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregationType := DURATION_KEY_MONTH
|
||||||
|
|
||||||
|
//test
|
||||||
|
influxDbScript := deviceRepo.aggregateTempQuery(aggregationType)
|
||||||
|
|
||||||
|
//assert
|
||||||
|
require.Equal(t, `import "influxdata/influxdb/schema"
|
||||||
|
weekData = from(bucket: "metrics")
|
||||||
|
|> range(start: -1w, stop: now())
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp" )
|
||||||
|
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|
||||||
|
monthData = from(bucket: "metrics_weekly")
|
||||||
|
|> range(start: -1mo, stop: -1w)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp" )
|
||||||
|
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|
||||||
|
union(tables: [weekData, monthData])
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> sort(columns: ["_time"], desc: false)
|
||||||
|
|> schema.fieldsAsCols()`, influxDbScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_aggregateTempQuery_Year(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//setup
|
||||||
|
mockCtrl := gomock.NewController(t)
|
||||||
|
defer mockCtrl.Finish()
|
||||||
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
|
||||||
|
|
||||||
|
deviceRepo := scrutinyRepository{
|
||||||
|
appConfig: fakeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregationType := DURATION_KEY_YEAR
|
||||||
|
|
||||||
|
//test
|
||||||
|
influxDbScript := deviceRepo.aggregateTempQuery(aggregationType)
|
||||||
|
|
||||||
|
//assert
|
||||||
|
require.Equal(t, `import "influxdata/influxdb/schema"
|
||||||
|
weekData = from(bucket: "metrics")
|
||||||
|
|> range(start: -1w, stop: now())
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp" )
|
||||||
|
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|
||||||
|
monthData = from(bucket: "metrics_weekly")
|
||||||
|
|> range(start: -1mo, stop: -1w)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp" )
|
||||||
|
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|
||||||
|
yearData = from(bucket: "metrics_monthly")
|
||||||
|
|> range(start: -1y, stop: -1mo)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp" )
|
||||||
|
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|
||||||
|
union(tables: [weekData, monthData, yearData])
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> sort(columns: ["_time"], desc: false)
|
||||||
|
|> schema.fieldsAsCols()`, influxDbScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_aggregateTempQuery_Forever(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//setup
|
||||||
|
mockCtrl := gomock.NewController(t)
|
||||||
|
defer mockCtrl.Finish()
|
||||||
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
|
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
|
||||||
|
|
||||||
|
deviceRepo := scrutinyRepository{
|
||||||
|
appConfig: fakeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregationType := DURATION_KEY_FOREVER
|
||||||
|
|
||||||
|
//test
|
||||||
|
influxDbScript := deviceRepo.aggregateTempQuery(aggregationType)
|
||||||
|
|
||||||
|
//assert
|
||||||
|
require.Equal(t, `import "influxdata/influxdb/schema"
|
||||||
|
weekData = from(bucket: "metrics")
|
||||||
|
|> range(start: -1w, stop: now())
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp" )
|
||||||
|
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|
||||||
|
monthData = from(bucket: "metrics_weekly")
|
||||||
|
|> range(start: -1mo, stop: -1w)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp" )
|
||||||
|
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|
||||||
|
yearData = from(bucket: "metrics_monthly")
|
||||||
|
|> range(start: -1y, stop: -1mo)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp" )
|
||||||
|
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|
||||||
|
foreverData = from(bucket: "metrics_yearly")
|
||||||
|
|> range(start: -10y, stop: -1y)
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "temp" )
|
||||||
|
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> toInt()
|
||||||
|
|
||||||
|
union(tables: [weekData, monthData, yearData, foreverData])
|
||||||
|
|> group(columns: ["device_wwn"])
|
||||||
|
|> sort(columns: ["_time"], desc: false)
|
||||||
|
|> schema.fieldsAsCols()`, influxDbScript)
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
// Temperature Format
|
|
||||||
// Date Format
|
|
||||||
// Device History window
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingEntry matches a setting row in the database
|
||||||
|
type SettingEntry struct {
|
||||||
|
//GORM attributes, see: http://gorm.io/docs/conventions.html
|
||||||
|
gorm.Model
|
||||||
|
|
||||||
|
SettingKeyName string `json:"setting_key_name" gorm:"unique;not null"`
|
||||||
|
SettingKeyDescription string `json:"setting_key_description"`
|
||||||
|
SettingDataType string `json:"setting_data_type"`
|
||||||
|
|
||||||
|
SettingValueNumeric int `json:"setting_value_numeric"`
|
||||||
|
SettingValueString string `json:"setting_value_string"`
|
||||||
|
SettingValueBool bool `json:"setting_value_bool"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SettingEntry) TableName() string {
|
||||||
|
return "settings"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// Settings is made up of parsed SettingEntry objects retrieved from the database
|
||||||
|
//type Settings struct {
|
||||||
|
// MetricsNotifyLevel pkg.MetricsNotifyLevel `json:"metrics.notify.level" mapstructure:"metrics.notify.level"`
|
||||||
|
// MetricsStatusFilterAttributes pkg.MetricsStatusFilterAttributes `json:"metrics.status.filter_attributes" mapstructure:"metrics.status.filter_attributes"`
|
||||||
|
// MetricsStatusThreshold pkg.MetricsStatusThreshold `json:"metrics.status.threshold" mapstructure:"metrics.status.threshold"`
|
||||||
|
//}
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
Theme string `json:"theme" mapstructure:"theme"`
|
||||||
|
Layout string `json:"layout" mapstructure:"layout"`
|
||||||
|
DashboardDisplay string `json:"dashboard_display" mapstructure:"dashboard_display"`
|
||||||
|
DashboardSort string `json:"dashboard_sort" mapstructure:"dashboard_sort"`
|
||||||
|
TemperatureUnit string `json:"temperature_unit" mapstructure:"temperature_unit"`
|
||||||
|
FileSizeSIUnits bool `json:"file_size_si_units" mapstructure:"file_size_si_units"`
|
||||||
|
|
||||||
|
Metrics struct {
|
||||||
|
NotifyLevel int `json:"notify_level" mapstructure:"notify_level"`
|
||||||
|
StatusFilterAttributes int `json:"status_filter_attributes" mapstructure:"status_filter_attributes"`
|
||||||
|
StatusThreshold int `json:"status_threshold" mapstructure:"status_threshold"`
|
||||||
|
} `json:"metrics" mapstructure:"metrics"`
|
||||||
|
}
|
||||||
@@ -29,20 +29,22 @@ const NotifyFailureTypeSmartFailure = "SmartFailure"
|
|||||||
const NotifyFailureTypeScrutinyFailure = "ScrutinyFailure"
|
const NotifyFailureTypeScrutinyFailure = "ScrutinyFailure"
|
||||||
|
|
||||||
// ShouldNotify check if the error Message should be filtered (level mismatch or filtered_attributes)
|
// ShouldNotify check if the error Message should be filtered (level mismatch or filtered_attributes)
|
||||||
func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLevel string, notifyFilterAttributes string) bool {
|
func ShouldNotify(device models.Device, smartAttrs measurements.Smart, statusThreshold pkg.MetricsStatusThreshold, statusFilterAttributes pkg.MetricsStatusFilterAttributes) bool {
|
||||||
// 1. check if the device is healthy
|
// 1. check if the device is healthy
|
||||||
if device.DeviceStatus == pkg.DeviceStatusPassed {
|
if device.DeviceStatus == pkg.DeviceStatusPassed {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//TODO: cannot check for warning notifyLevel yet.
|
||||||
|
|
||||||
// setup constants for comparison
|
// setup constants for comparison
|
||||||
var requiredDeviceStatus pkg.DeviceStatus
|
var requiredDeviceStatus pkg.DeviceStatus
|
||||||
var requiredAttrStatus pkg.AttributeStatus
|
var requiredAttrStatus pkg.AttributeStatus
|
||||||
if notifyLevel == pkg.NotifyLevelFail {
|
if statusThreshold == pkg.MetricsStatusThresholdBoth {
|
||||||
// either scrutiny or smart failures should trigger an email
|
// either scrutiny or smart failures should trigger an email
|
||||||
requiredDeviceStatus = pkg.DeviceStatusSet(pkg.DeviceStatusFailedSmart, pkg.DeviceStatusFailedScrutiny)
|
requiredDeviceStatus = pkg.DeviceStatusSet(pkg.DeviceStatusFailedSmart, pkg.DeviceStatusFailedScrutiny)
|
||||||
requiredAttrStatus = pkg.AttributeStatusSet(pkg.AttributeStatusFailedSmart, pkg.AttributeStatusFailedScrutiny)
|
requiredAttrStatus = pkg.AttributeStatusSet(pkg.AttributeStatusFailedSmart, pkg.AttributeStatusFailedScrutiny)
|
||||||
} else if notifyLevel == pkg.NotifyLevelFailSmart {
|
} else if statusThreshold == pkg.MetricsStatusThresholdSmart {
|
||||||
//only smart failures
|
//only smart failures
|
||||||
requiredDeviceStatus = pkg.DeviceStatusFailedSmart
|
requiredDeviceStatus = pkg.DeviceStatusFailedSmart
|
||||||
requiredAttrStatus = pkg.AttributeStatusFailedSmart
|
requiredAttrStatus = pkg.AttributeStatusFailedSmart
|
||||||
@@ -53,9 +55,9 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLev
|
|||||||
|
|
||||||
// 2. check if the attributes that are failing should be filtered (non-critical)
|
// 2. check if the attributes that are failing should be filtered (non-critical)
|
||||||
// 3. for any unfiltered attribute, store the failure reason (Smart or Scrutiny)
|
// 3. for any unfiltered attribute, store the failure reason (Smart or Scrutiny)
|
||||||
if notifyFilterAttributes == pkg.NotifyFilterAttributesCritical {
|
if statusFilterAttributes == pkg.MetricsStatusFilterAttributesCritical {
|
||||||
hasFailingCriticalAttr := false
|
hasFailingCriticalAttr := false
|
||||||
var statusFailingCrtiticalAttr pkg.AttributeStatus
|
var statusFailingCriticalAttr pkg.AttributeStatus
|
||||||
|
|
||||||
for attrId, attrData := range smartAttrs.Attributes {
|
for attrId, attrData := range smartAttrs.Attributes {
|
||||||
//find failing attribute
|
//find failing attribute
|
||||||
@@ -64,7 +66,7 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLev
|
|||||||
}
|
}
|
||||||
|
|
||||||
// merge the status's of all critical attributes
|
// merge the status's of all critical attributes
|
||||||
statusFailingCrtiticalAttr = pkg.AttributeStatusSet(statusFailingCrtiticalAttr, attrData.GetStatus())
|
statusFailingCriticalAttr = pkg.AttributeStatusSet(statusFailingCriticalAttr, attrData.GetStatus())
|
||||||
|
|
||||||
//found a failing attribute, see if its critical
|
//found a failing attribute, see if its critical
|
||||||
if device.IsScsi() && thresholds.ScsiMetadata[attrId].Critical {
|
if device.IsScsi() && thresholds.ScsiMetadata[attrId].Critical {
|
||||||
@@ -89,7 +91,7 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLev
|
|||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
// check if any of the critical attributes have a status that we're looking for
|
// check if any of the critical attributes have a status that we're looking for
|
||||||
return pkg.AttributeStatusHas(statusFailingCrtiticalAttr, requiredAttrStatus)
|
return pkg.AttributeStatusHas(statusFailingCriticalAttr, requiredAttrStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
@@ -99,12 +101,13 @@ func ShouldNotify(device models.Device, smartAttrs measurements.Smart, notifyLev
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: include host and/or user label for device.
|
// TODO: include user label for device.
|
||||||
type Payload struct {
|
type Payload struct {
|
||||||
DeviceType string `json:"device_type"` //ATA/SCSI/NVMe
|
HostId string `json:"host_id,omitempty"` //host id (optional)
|
||||||
DeviceName string `json:"device_name"` //dev/sda
|
DeviceType string `json:"device_type"` //ATA/SCSI/NVMe
|
||||||
DeviceSerial string `json:"device_serial"` //WDDJ324KSO
|
DeviceName string `json:"device_name"` //dev/sda
|
||||||
Test bool `json:"test"` // false
|
DeviceSerial string `json:"device_serial"` //WDDJ324KSO
|
||||||
|
Test bool `json:"test"` // false
|
||||||
|
|
||||||
//private, populated during init (marked as Public for JSON serialization)
|
//private, populated during init (marked as Public for JSON serialization)
|
||||||
Date string `json:"date"` //populated by Send function.
|
Date string `json:"date"` //populated by Send function.
|
||||||
@@ -113,8 +116,9 @@ type Payload struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPayload(device models.Device, test bool) Payload {
|
func NewPayload(device models.Device, test bool, currentTime ...time.Time) Payload {
|
||||||
payload := Payload{
|
payload := Payload{
|
||||||
|
HostId: strings.TrimSpace(device.HostId),
|
||||||
DeviceType: device.DeviceType,
|
DeviceType: device.DeviceType,
|
||||||
DeviceName: device.DeviceName,
|
DeviceName: device.DeviceName,
|
||||||
DeviceSerial: device.SerialNumber,
|
DeviceSerial: device.SerialNumber,
|
||||||
@@ -122,7 +126,13 @@ func NewPayload(device models.Device, test bool) Payload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//validate that the Payload is populated
|
//validate that the Payload is populated
|
||||||
sendDate := time.Now()
|
var sendDate time.Time
|
||||||
|
if currentTime != nil && len(currentTime) > 0 {
|
||||||
|
sendDate = currentTime[0]
|
||||||
|
} else {
|
||||||
|
sendDate = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
payload.Date = sendDate.Format(time.RFC3339)
|
payload.Date = sendDate.Format(time.RFC3339)
|
||||||
payload.FailureType = payload.GenerateFailureType(device.DeviceStatus)
|
payload.FailureType = payload.GenerateFailureType(device.DeviceStatus)
|
||||||
payload.Subject = payload.GenerateSubject()
|
payload.Subject = payload.GenerateSubject()
|
||||||
@@ -146,25 +156,39 @@ func (p *Payload) GenerateFailureType(deviceStatus pkg.DeviceStatus) string {
|
|||||||
|
|
||||||
func (p *Payload) GenerateSubject() string {
|
func (p *Payload) GenerateSubject() string {
|
||||||
//generate a detailed failure message
|
//generate a detailed failure message
|
||||||
return fmt.Sprintf("Scrutiny SMART error (%s) detected on device: %s", p.FailureType, p.DeviceName)
|
var subject string
|
||||||
|
if len(p.HostId) > 0 {
|
||||||
|
subject = fmt.Sprintf("Scrutiny SMART error (%s) detected on [host]device: [%s]%s", p.FailureType, p.HostId, p.DeviceName)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("Scrutiny SMART error (%s) detected on device: %s", p.FailureType, p.DeviceName)
|
||||||
|
}
|
||||||
|
return subject
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Payload) GenerateMessage() string {
|
func (p *Payload) GenerateMessage() string {
|
||||||
//generate a detailed failure message
|
//generate a detailed failure message
|
||||||
message := fmt.Sprintf(
|
|
||||||
`Scrutiny SMART error notification for device: %s
|
|
||||||
Failure Type: %s
|
|
||||||
Device Name: %s
|
|
||||||
Device Serial: %s
|
|
||||||
Device Type: %s
|
|
||||||
|
|
||||||
Date: %s`, p.DeviceName, p.FailureType, p.DeviceName, p.DeviceSerial, p.DeviceType, p.Date)
|
messageParts := []string{}
|
||||||
|
|
||||||
if p.Test {
|
messageParts = append(messageParts, fmt.Sprintf("Scrutiny SMART error notification for device: %s", p.DeviceName))
|
||||||
message = "TEST NOTIFICATION:\n" + message
|
if len(p.HostId) > 0 {
|
||||||
|
messageParts = append(messageParts, fmt.Sprintf("Host Id: %s", p.HostId))
|
||||||
}
|
}
|
||||||
|
|
||||||
return message
|
messageParts = append(messageParts,
|
||||||
|
fmt.Sprintf("Failure Type: %s", p.FailureType),
|
||||||
|
fmt.Sprintf("Device Name: %s", p.DeviceName),
|
||||||
|
fmt.Sprintf("Device Serial: %s", p.DeviceSerial),
|
||||||
|
fmt.Sprintf("Device Type: %s", p.DeviceType),
|
||||||
|
"",
|
||||||
|
fmt.Sprintf("Date: %s", p.Date),
|
||||||
|
)
|
||||||
|
|
||||||
|
if p.Test {
|
||||||
|
messageParts = append([]string{"TEST NOTIFICATION:"}, messageParts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(messageParts, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(logger logrus.FieldLogger, appconfig config.Interface, device models.Device, test bool) Notify {
|
func New(logger logrus.FieldLogger, appconfig config.Interface, device models.Device, test bool) Notify {
|
||||||
@@ -285,6 +309,9 @@ func (n *Notify) SendScriptNotification(scriptUrl string) error {
|
|||||||
copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_DEVICE_TYPE=%s", n.Payload.DeviceType))
|
copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_DEVICE_TYPE=%s", n.Payload.DeviceType))
|
||||||
copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_DEVICE_SERIAL=%s", n.Payload.DeviceSerial))
|
copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_DEVICE_SERIAL=%s", n.Payload.DeviceSerial))
|
||||||
copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_MESSAGE=%s", n.Payload.Message))
|
copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_MESSAGE=%s", n.Payload.Message))
|
||||||
|
if len(n.Payload.HostId) > 0 {
|
||||||
|
copyEnv = append(copyEnv, fmt.Sprintf("SCRUTINY_HOST_ID=%s", n.Payload.HostId))
|
||||||
|
}
|
||||||
err := utils.CmdExec(scriptPath, []string{}, "", copyEnv, "")
|
err := utils.CmdExec(scriptPath, []string{}, "", copyEnv, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
n.Logger.Errorf("An error occurred while executing script %s: %v", scriptPath, err)
|
n.Logger.Errorf("An error occurred while executing script %s: %v", scriptPath, err)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package notify
|
package notify
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg"
|
"github.com/analogj/scrutiny/webapp/backend/pkg"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestShouldNotify_MustSkipPassingDevices(t *testing.T) {
|
func TestShouldNotify_MustSkipPassingDevices(t *testing.T) {
|
||||||
@@ -15,56 +17,56 @@ func TestShouldNotify_MustSkipPassingDevices(t *testing.T) {
|
|||||||
DeviceStatus: pkg.DeviceStatusPassed,
|
DeviceStatus: pkg.DeviceStatusPassed,
|
||||||
}
|
}
|
||||||
smartAttrs := measurements.Smart{}
|
smartAttrs := measurements.Smart{}
|
||||||
notifyLevel := pkg.NotifyLevelFail
|
statusThreshold := pkg.MetricsStatusThresholdBoth
|
||||||
notifyFilterAttributes := pkg.NotifyFilterAttributesAll
|
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
|
||||||
|
|
||||||
//assert
|
//assert
|
||||||
require.False(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes))
|
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldNotify_NotifyLevelFail_FailingSmartDevice(t *testing.T) {
|
func TestShouldNotify_MetricsStatusThresholdBoth_FailingSmartDevice(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
//setup
|
//setup
|
||||||
device := models.Device{
|
device := models.Device{
|
||||||
DeviceStatus: pkg.DeviceStatusFailedSmart,
|
DeviceStatus: pkg.DeviceStatusFailedSmart,
|
||||||
}
|
}
|
||||||
smartAttrs := measurements.Smart{}
|
smartAttrs := measurements.Smart{}
|
||||||
notifyLevel := pkg.NotifyLevelFail
|
statusThreshold := pkg.MetricsStatusThresholdBoth
|
||||||
notifyFilterAttributes := pkg.NotifyFilterAttributesAll
|
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
|
||||||
|
|
||||||
//assert
|
//assert
|
||||||
require.True(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes))
|
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldNotify_NotifyLevelFailSmart_FailingSmartDevice(t *testing.T) {
|
func TestShouldNotify_MetricsStatusThresholdSmart_FailingSmartDevice(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
//setup
|
//setup
|
||||||
device := models.Device{
|
device := models.Device{
|
||||||
DeviceStatus: pkg.DeviceStatusFailedSmart,
|
DeviceStatus: pkg.DeviceStatusFailedSmart,
|
||||||
}
|
}
|
||||||
smartAttrs := measurements.Smart{}
|
smartAttrs := measurements.Smart{}
|
||||||
notifyLevel := pkg.NotifyLevelFailSmart
|
statusThreshold := pkg.MetricsStatusThresholdSmart
|
||||||
notifyFilterAttributes := pkg.NotifyFilterAttributesAll
|
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
|
||||||
|
|
||||||
//assert
|
//assert
|
||||||
require.True(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes))
|
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldNotify_NotifyLevelFailScrutiny_FailingSmartDevice(t *testing.T) {
|
func TestShouldNotify_MetricsStatusThresholdScrutiny_FailingSmartDevice(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
//setup
|
//setup
|
||||||
device := models.Device{
|
device := models.Device{
|
||||||
DeviceStatus: pkg.DeviceStatusFailedSmart,
|
DeviceStatus: pkg.DeviceStatusFailedSmart,
|
||||||
}
|
}
|
||||||
smartAttrs := measurements.Smart{}
|
smartAttrs := measurements.Smart{}
|
||||||
notifyLevel := pkg.NotifyLevelFailScrutiny
|
statusThreshold := pkg.MetricsStatusThresholdScrutiny
|
||||||
notifyFilterAttributes := pkg.NotifyFilterAttributesAll
|
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesAll
|
||||||
|
|
||||||
//assert
|
//assert
|
||||||
require.False(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes))
|
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldNotify_NotifyFilterAttributesCritical_WithCriticalAttrs(t *testing.T) {
|
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithCriticalAttrs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
//setup
|
//setup
|
||||||
device := models.Device{
|
device := models.Device{
|
||||||
@@ -75,14 +77,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithCriticalAttrs(t *testin
|
|||||||
Status: pkg.AttributeStatusFailedSmart,
|
Status: pkg.AttributeStatusFailedSmart,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
notifyLevel := pkg.NotifyLevelFail
|
statusThreshold := pkg.MetricsStatusThresholdBoth
|
||||||
notifyFilterAttributes := pkg.NotifyFilterAttributesCritical
|
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
|
||||||
|
|
||||||
//assert
|
//assert
|
||||||
require.True(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes))
|
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldNotify_NotifyFilterAttributesCritical_WithMultipleCriticalAttrs(t *testing.T) {
|
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithMultipleCriticalAttrs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
//setup
|
//setup
|
||||||
device := models.Device{
|
device := models.Device{
|
||||||
@@ -96,14 +98,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithMultipleCriticalAttrs(t
|
|||||||
Status: pkg.AttributeStatusFailedScrutiny,
|
Status: pkg.AttributeStatusFailedScrutiny,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
notifyLevel := pkg.NotifyLevelFail
|
statusThreshold := pkg.MetricsStatusThresholdBoth
|
||||||
notifyFilterAttributes := pkg.NotifyFilterAttributesCritical
|
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
|
||||||
|
|
||||||
//assert
|
//assert
|
||||||
require.True(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes))
|
require.True(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldNotify_NotifyFilterAttributesCritical_WithNoCriticalAttrs(t *testing.T) {
|
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoCriticalAttrs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
//setup
|
//setup
|
||||||
device := models.Device{
|
device := models.Device{
|
||||||
@@ -114,14 +116,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithNoCriticalAttrs(t *test
|
|||||||
Status: pkg.AttributeStatusFailedSmart,
|
Status: pkg.AttributeStatusFailedSmart,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
notifyLevel := pkg.NotifyLevelFail
|
statusThreshold := pkg.MetricsStatusThresholdBoth
|
||||||
notifyFilterAttributes := pkg.NotifyFilterAttributesCritical
|
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
|
||||||
|
|
||||||
//assert
|
//assert
|
||||||
require.False(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes))
|
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldNotify_NotifyFilterAttributesCritical_WithNoFailingCriticalAttrs(t *testing.T) {
|
func TestShouldNotify_MetricsStatusFilterAttributesCritical_WithNoFailingCriticalAttrs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
//setup
|
//setup
|
||||||
device := models.Device{
|
device := models.Device{
|
||||||
@@ -132,14 +134,14 @@ func TestShouldNotify_NotifyFilterAttributesCritical_WithNoFailingCriticalAttrs(
|
|||||||
Status: pkg.AttributeStatusPassed,
|
Status: pkg.AttributeStatusPassed,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
notifyLevel := pkg.NotifyLevelFail
|
statusThreshold := pkg.MetricsStatusThresholdBoth
|
||||||
notifyFilterAttributes := pkg.NotifyFilterAttributesCritical
|
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
|
||||||
|
|
||||||
//assert
|
//assert
|
||||||
require.False(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes))
|
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldNotify_NotifyFilterAttributesCritical_NotifyLevelFailSmart_WithCriticalAttrsFailingScrutiny(t *testing.T) {
|
func TestShouldNotify_MetricsStatusFilterAttributesCritical_MetricsStatusThresholdSmart_WithCriticalAttrsFailingScrutiny(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
//setup
|
//setup
|
||||||
device := models.Device{
|
device := models.Device{
|
||||||
@@ -153,9 +155,90 @@ func TestShouldNotify_NotifyFilterAttributesCritical_NotifyLevelFailSmart_WithCr
|
|||||||
Status: pkg.AttributeStatusFailedScrutiny,
|
Status: pkg.AttributeStatusFailedScrutiny,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
notifyLevel := pkg.NotifyLevelFailSmart
|
statusThreshold := pkg.MetricsStatusThresholdSmart
|
||||||
notifyFilterAttributes := pkg.NotifyFilterAttributesCritical
|
notifyFilterAttributes := pkg.MetricsStatusFilterAttributesCritical
|
||||||
|
|
||||||
//assert
|
//assert
|
||||||
require.False(t, ShouldNotify(device, smartAttrs, notifyLevel, notifyFilterAttributes))
|
require.False(t, ShouldNotify(device, smartAttrs, statusThreshold, notifyFilterAttributes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPayload(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//setup
|
||||||
|
device := models.Device{
|
||||||
|
SerialNumber: "FAKEWDDJ324KSO",
|
||||||
|
DeviceType: pkg.DeviceProtocolAta,
|
||||||
|
DeviceName: "/dev/sda",
|
||||||
|
DeviceStatus: pkg.DeviceStatusFailedScrutiny,
|
||||||
|
}
|
||||||
|
currentTime := time.Now()
|
||||||
|
//test
|
||||||
|
|
||||||
|
payload := NewPayload(device, false, currentTime)
|
||||||
|
|
||||||
|
//assert
|
||||||
|
require.Equal(t, "Scrutiny SMART error (ScrutinyFailure) detected on device: /dev/sda", payload.Subject)
|
||||||
|
require.Equal(t, fmt.Sprintf(`Scrutiny SMART error notification for device: /dev/sda
|
||||||
|
Failure Type: ScrutinyFailure
|
||||||
|
Device Name: /dev/sda
|
||||||
|
Device Serial: FAKEWDDJ324KSO
|
||||||
|
Device Type: ATA
|
||||||
|
|
||||||
|
Date: %s`, currentTime.Format(time.RFC3339)), payload.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPayload_TestMode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//setup
|
||||||
|
device := models.Device{
|
||||||
|
SerialNumber: "FAKEWDDJ324KSO",
|
||||||
|
DeviceType: pkg.DeviceProtocolAta,
|
||||||
|
DeviceName: "/dev/sda",
|
||||||
|
DeviceStatus: pkg.DeviceStatusFailedScrutiny,
|
||||||
|
}
|
||||||
|
currentTime := time.Now()
|
||||||
|
//test
|
||||||
|
|
||||||
|
payload := NewPayload(device, true, currentTime)
|
||||||
|
|
||||||
|
//assert
|
||||||
|
require.Equal(t, "Scrutiny SMART error (EmailTest) detected on device: /dev/sda", payload.Subject)
|
||||||
|
require.Equal(t, fmt.Sprintf(`TEST NOTIFICATION:
|
||||||
|
Scrutiny SMART error notification for device: /dev/sda
|
||||||
|
Failure Type: EmailTest
|
||||||
|
Device Name: /dev/sda
|
||||||
|
Device Serial: FAKEWDDJ324KSO
|
||||||
|
Device Type: ATA
|
||||||
|
|
||||||
|
Date: %s`, currentTime.Format(time.RFC3339)), payload.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPayload_WithHostId(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
//setup
|
||||||
|
device := models.Device{
|
||||||
|
SerialNumber: "FAKEWDDJ324KSO",
|
||||||
|
DeviceType: pkg.DeviceProtocolAta,
|
||||||
|
DeviceName: "/dev/sda",
|
||||||
|
DeviceStatus: pkg.DeviceStatusFailedScrutiny,
|
||||||
|
HostId: "custom-host",
|
||||||
|
}
|
||||||
|
currentTime := time.Now()
|
||||||
|
//test
|
||||||
|
|
||||||
|
payload := NewPayload(device, false, currentTime)
|
||||||
|
|
||||||
|
//assert
|
||||||
|
require.Equal(t, "Scrutiny SMART error (ScrutinyFailure) detected on [host]device: [custom-host]/dev/sda", payload.Subject)
|
||||||
|
require.Equal(t, fmt.Sprintf(`Scrutiny SMART error notification for device: /dev/sda
|
||||||
|
Host Id: custom-host
|
||||||
|
Failure Type: ScrutinyFailure
|
||||||
|
Device Name: /dev/sda
|
||||||
|
Device Serial: FAKEWDDJ324KSO
|
||||||
|
Device Type: ATA
|
||||||
|
|
||||||
|
Date: %s`, currentTime.Format(time.RFC3339)), payload.Message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.4.14"
|
const VERSION = "0.5.0"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func DeleteDevice(c *gin.Context) {
|
func DeleteDevice(c *gin.Context) {
|
||||||
logger := c.MustGet("LOGGER").(logrus.FieldLogger)
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
||||||
|
|
||||||
err := deviceRepo.DeleteDevice(c, c.Param("wwn"))
|
err := deviceRepo.DeleteDevice(c, c.Param("wwn"))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func GetDeviceDetails(c *gin.Context) {
|
func GetDeviceDetails(c *gin.Context) {
|
||||||
logger := c.MustGet("LOGGER").(logrus.FieldLogger)
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
||||||
|
|
||||||
device, err := deviceRepo.GetDeviceDetails(c, c.Param("wwn"))
|
device, err := deviceRepo.GetDeviceDetails(c, c.Param("wwn"))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func GetDevicesSummary(c *gin.Context) {
|
func GetDevicesSummary(c *gin.Context) {
|
||||||
logger := c.MustGet("LOGGER").(logrus.FieldLogger)
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
||||||
|
|
||||||
summary, err := deviceRepo.GetSummary(c)
|
summary, err := deviceRepo.GetSummary(c)
|
||||||
@@ -18,6 +18,7 @@ func GetDevicesSummary(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//this must match DeviceSummaryWrapper (webapp/backend/pkg/models/device_summary.go)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func GetDevicesSummaryTempHistory(c *gin.Context) {
|
func GetDevicesSummaryTempHistory(c *gin.Context) {
|
||||||
logger := c.MustGet("LOGGER").(logrus.FieldLogger)
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
||||||
|
|
||||||
durationKey, exists := c.GetQuery("duration_key")
|
durationKey, exists := c.GetQuery("duration_key")
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetSettings(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
||||||
|
|
||||||
|
settings, err := deviceRepo.LoadSettings(c)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while retrieving settings", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"settings": settings,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/samber/lo"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
// This function is run everytime a collector is about to start a run. It can be used to update device metadata.
|
// This function is run everytime a collector is about to start a run. It can be used to update device metadata.
|
||||||
func RegisterDevices(c *gin.Context) {
|
func RegisterDevices(c *gin.Context) {
|
||||||
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
||||||
logger := c.MustGet("LOGGER").(logrus.FieldLogger)
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
|
||||||
var collectorDeviceWrapper models.DeviceWrapper
|
var collectorDeviceWrapper models.DeviceWrapper
|
||||||
err := c.BindJSON(&collectorDeviceWrapper)
|
err := c.BindJSON(&collectorDeviceWrapper)
|
||||||
@@ -22,8 +23,13 @@ func RegisterDevices(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//filter any device with empty wwn (they are invalid)
|
||||||
|
detectedStorageDevices := lo.Filter[models.Device](collectorDeviceWrapper.Data, func(dev models.Device, _ int) bool {
|
||||||
|
return len(dev.WWN) > 0
|
||||||
|
})
|
||||||
|
|
||||||
errs := []error{}
|
errs := []error{}
|
||||||
for _, dev := range collectorDeviceWrapper.Data {
|
for _, dev := range detectedStorageDevices {
|
||||||
//insert devices into DB (and update specified columns if device is already registered)
|
//insert devices into DB (and update specified columns if device is already registered)
|
||||||
// update device fields that may change: (DeviceType, HostID)
|
// update device fields that may change: (DeviceType, HostID)
|
||||||
if err := deviceRepo.RegisterDevice(c, dev); err != nil {
|
if err := deviceRepo.RegisterDevice(c, dev); err != nil {
|
||||||
@@ -40,7 +46,7 @@ func RegisterDevices(c *gin.Context) {
|
|||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusOK, models.DeviceWrapper{
|
c.JSON(http.StatusOK, models.DeviceWrapper{
|
||||||
Success: true,
|
Success: true,
|
||||||
Data: collectorDeviceWrapper.Data,
|
Data: detectedStorageDevices,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
|
||||||
|
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SaveSettings(c *gin.Context) {
|
||||||
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
||||||
|
|
||||||
|
var settings models.Settings
|
||||||
|
err := c.BindJSON(&settings)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("Cannot parse updated settings", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = deviceRepo.SaveSettings(c, settings)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorln("An error occurred while saving settings", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"settings": settings,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
// Send test notification
|
// Send test notification
|
||||||
func SendTestNotification(c *gin.Context) {
|
func SendTestNotification(c *gin.Context) {
|
||||||
appConfig := c.MustGet("CONFIG").(config.Interface)
|
appConfig := c.MustGet("CONFIG").(config.Interface)
|
||||||
logger := c.MustGet("LOGGER").(logrus.FieldLogger)
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
|
|
||||||
testNotify := notify.New(
|
testNotify := notify.New(
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg"
|
"github.com/analogj/scrutiny/webapp/backend/pkg"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/config"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/config"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
|
||||||
@@ -13,13 +14,17 @@ import (
|
|||||||
|
|
||||||
func UploadDeviceMetrics(c *gin.Context) {
|
func UploadDeviceMetrics(c *gin.Context) {
|
||||||
//db := c.MustGet("DB").(*gorm.DB)
|
//db := c.MustGet("DB").(*gorm.DB)
|
||||||
logger := c.MustGet("LOGGER").(logrus.FieldLogger)
|
logger := c.MustGet("LOGGER").(*logrus.Entry)
|
||||||
appConfig := c.MustGet("CONFIG").(config.Interface)
|
appConfig := c.MustGet("CONFIG").(config.Interface)
|
||||||
//influxWriteDb := c.MustGet("INFLUXDB_WRITE").(*api.WriteAPIBlocking)
|
//influxWriteDb := c.MustGet("INFLUXDB_WRITE").(*api.WriteAPIBlocking)
|
||||||
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
|
||||||
|
|
||||||
//appConfig := c.MustGet("CONFIG").(config.Interface)
|
//appConfig := c.MustGet("CONFIG").(config.Interface)
|
||||||
|
|
||||||
|
if c.Param("wwn") == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false})
|
||||||
|
}
|
||||||
|
|
||||||
var collectorSmartData collector.SmartInfo
|
var collectorSmartData collector.SmartInfo
|
||||||
err := c.BindJSON(&collectorSmartData)
|
err := c.BindJSON(&collectorSmartData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,7 +68,12 @@ func UploadDeviceMetrics(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//check for error
|
//check for error
|
||||||
if notify.ShouldNotify(updatedDevice, smartData, appConfig.GetString("notify.level"), appConfig.GetString("notify.filter_attributes")) {
|
if notify.ShouldNotify(
|
||||||
|
updatedDevice,
|
||||||
|
smartData,
|
||||||
|
pkg.MetricsStatusThreshold(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY))),
|
||||||
|
pkg.MetricsStatusFilterAttributes(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY))),
|
||||||
|
) {
|
||||||
//send notifications
|
//send notifications
|
||||||
|
|
||||||
liveNotify := notify.New(
|
liveNotify := notify.New(
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ import (
|
|||||||
var timeFormat = "02/Jan/2006:15:04:05 -0700"
|
var timeFormat = "02/Jan/2006:15:04:05 -0700"
|
||||||
|
|
||||||
// Logger is the logrus logger handler
|
// Logger is the logrus logger handler
|
||||||
func LoggerMiddleware(logger logrus.FieldLogger) gin.HandlerFunc {
|
func LoggerMiddleware(logger *logrus.Entry) gin.HandlerFunc {
|
||||||
|
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hostname = "unknow"
|
hostname = "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/config"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/config"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -14,6 +15,14 @@ func RepositoryMiddleware(appConfig config.Interface, globalLogger logrus.FieldL
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensure the settings have been loaded into the app config during startup.
|
||||||
|
_, err = deviceRepo.LoadSettings(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//settings.UpdateSettingEntries()
|
||||||
|
|
||||||
//TODO: determine where we can call defer deviceRepo.Close()
|
//TODO: determine where we can call defer deviceRepo.Close()
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Set("DEVICE_REPOSITORY", deviceRepo)
|
c.Set("DEVICE_REPOSITORY", deviceRepo)
|
||||||
|
|||||||
@@ -9,18 +9,17 @@ import (
|
|||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/web/middleware"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/web/middleware"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AppEngine struct {
|
type AppEngine struct {
|
||||||
Config config.Interface
|
Config config.Interface
|
||||||
|
Logger *logrus.Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ae *AppEngine) Setup(logger logrus.FieldLogger) *gin.Engine {
|
func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
|
||||||
r.Use(middleware.LoggerMiddleware(logger))
|
r.Use(middleware.LoggerMiddleware(logger))
|
||||||
@@ -36,6 +35,10 @@ func (ae *AppEngine) Setup(logger logrus.FieldLogger) *gin.Engine {
|
|||||||
api := base.Group("/api")
|
api := base.Group("/api")
|
||||||
{
|
{
|
||||||
api.GET("/health", func(c *gin.Context) {
|
api.GET("/health", func(c *gin.Context) {
|
||||||
|
//TODO:
|
||||||
|
// check if the /web folder is populated.
|
||||||
|
// check if access to influxdb
|
||||||
|
// check if access to sqlitedb.
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
})
|
})
|
||||||
@@ -50,6 +53,8 @@ func (ae *AppEngine) Setup(logger logrus.FieldLogger) *gin.Engine {
|
|||||||
api.GET("/device/:wwn/details", handler.GetDeviceDetails) //used by Details
|
api.GET("/device/:wwn/details", handler.GetDeviceDetails) //used by Details
|
||||||
api.DELETE("/device/:wwn", handler.DeleteDevice) //used by UI to delete device
|
api.DELETE("/device/:wwn", handler.DeleteDevice) //used by UI to delete device
|
||||||
|
|
||||||
|
api.GET("/settings", handler.GetSettings) //used to get settings
|
||||||
|
api.POST("/settings", handler.SaveSettings) //used to save settings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,26 +80,6 @@ func (ae *AppEngine) Start() error {
|
|||||||
gin.SetMode(gin.DebugMode)
|
gin.SetMode(gin.DebugMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := logrus.New()
|
|
||||||
//set default log level
|
|
||||||
logLevel, err := logrus.ParseLevel(ae.Config.GetString("log.level"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logger.SetLevel(logLevel)
|
|
||||||
//set the log file if present
|
|
||||||
if len(ae.Config.GetString("log.file")) != 0 {
|
|
||||||
logFile, err := os.OpenFile(ae.Config.GetString("log.file"), os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
defer logFile.Close()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Errorf("Failed to open log file %s for output: %s", ae.Config.GetString("log.file"), err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
//configure the logrus default
|
|
||||||
logger.SetOutput(io.MultiWriter(os.Stderr, logFile))
|
|
||||||
}
|
|
||||||
|
|
||||||
//check if the database parent directory exists, fail here rather than in a handler.
|
//check if the database parent directory exists, fail here rather than in a handler.
|
||||||
if !utils.FileExists(filepath.Dir(ae.Config.GetString("web.database.location"))) {
|
if !utils.FileExists(filepath.Dir(ae.Config.GetString("web.database.location"))) {
|
||||||
return errors.ConfigValidationError(fmt.Sprintf(
|
return errors.ConfigValidationError(fmt.Sprintf(
|
||||||
@@ -102,7 +87,7 @@ func (ae *AppEngine) Start() error {
|
|||||||
filepath.Dir(ae.Config.GetString("web.database.location"))))
|
filepath.Dir(ae.Config.GetString("web.database.location"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
r := ae.Setup(logger)
|
r := ae.Setup(ae.Logger)
|
||||||
|
|
||||||
return r.Run(fmt.Sprintf("%s:%s", ae.Config.GetString("web.listen.host"), ae.Config.GetString("web.listen.port")))
|
return r.Run(fmt.Sprintf("%s:%s", ae.Config.GetString("web.listen.host"), ae.Config.GetString("web.listen.port")))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package web_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg"
|
"github.com/analogj/scrutiny/webapp/backend/pkg"
|
||||||
|
"github.com/analogj/scrutiny/webapp/backend/pkg/config"
|
||||||
mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock"
|
mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
|
||||||
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
|
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
|
||||||
@@ -89,6 +91,8 @@ func (suite *ServerTestSuite) TestHealthRoute() {
|
|||||||
mockCtrl := gomock.NewController(suite.T())
|
mockCtrl := gomock.NewController(suite.T())
|
||||||
defer mockCtrl.Finish()
|
defer mockCtrl.Finish()
|
||||||
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
|
||||||
fakeConfig.EXPECT().GetString("web.database.location").Return(path.Join(parentPath, "scrutiny_test.db")).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.database.location").Return(path.Join(parentPath, "scrutiny_test.db")).AnyTimes()
|
||||||
fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes()
|
||||||
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
||||||
@@ -111,7 +115,7 @@ func (suite *ServerTestSuite) TestHealthRoute() {
|
|||||||
Config: fakeConfig,
|
Config: fakeConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
router := ae.Setup(logrus.New())
|
router := ae.Setup(logrus.WithField("test", suite.T().Name()))
|
||||||
|
|
||||||
//test
|
//test
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -130,6 +134,8 @@ func (suite *ServerTestSuite) TestRegisterDevicesRoute() {
|
|||||||
mockCtrl := gomock.NewController(suite.T())
|
mockCtrl := gomock.NewController(suite.T())
|
||||||
defer mockCtrl.Finish()
|
defer mockCtrl.Finish()
|
||||||
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
|
||||||
fakeConfig.EXPECT().GetString("web.database.location").Return(path.Join(parentPath, "scrutiny_test.db")).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.database.location").Return(path.Join(parentPath, "scrutiny_test.db")).AnyTimes()
|
||||||
fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.src.frontend.path").Return(parentPath).AnyTimes()
|
||||||
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
||||||
@@ -150,7 +156,7 @@ func (suite *ServerTestSuite) TestRegisterDevicesRoute() {
|
|||||||
ae := web.AppEngine{
|
ae := web.AppEngine{
|
||||||
Config: fakeConfig,
|
Config: fakeConfig,
|
||||||
}
|
}
|
||||||
router := ae.Setup(logrus.New())
|
router := ae.Setup(logrus.WithField("test", suite.T().Name()))
|
||||||
file, err := os.Open("testdata/register-devices-req.json")
|
file, err := os.Open("testdata/register-devices-req.json")
|
||||||
require.NoError(suite.T(), err)
|
require.NoError(suite.T(), err)
|
||||||
|
|
||||||
@@ -170,6 +176,8 @@ func (suite *ServerTestSuite) TestUploadDeviceMetricsRoute() {
|
|||||||
mockCtrl := gomock.NewController(suite.T())
|
mockCtrl := gomock.NewController(suite.T())
|
||||||
defer mockCtrl.Finish()
|
defer mockCtrl.Finish()
|
||||||
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
|
||||||
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
||||||
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
||||||
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
||||||
@@ -186,13 +194,14 @@ func (suite *ServerTestSuite) TestUploadDeviceMetricsRoute() {
|
|||||||
} else {
|
} else {
|
||||||
fakeConfig.EXPECT().GetString("web.influxdb.host").Return("localhost").AnyTimes()
|
fakeConfig.EXPECT().GetString("web.influxdb.host").Return("localhost").AnyTimes()
|
||||||
}
|
}
|
||||||
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
|
||||||
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll))
|
||||||
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth))
|
||||||
|
|
||||||
ae := web.AppEngine{
|
ae := web.AppEngine{
|
||||||
Config: fakeConfig,
|
Config: fakeConfig,
|
||||||
}
|
}
|
||||||
router := ae.Setup(logrus.New())
|
router := ae.Setup(logrus.WithField("test", suite.T().Name()))
|
||||||
devicesfile, err := os.Open("testdata/register-devices-single-req.json")
|
devicesfile, err := os.Open("testdata/register-devices-single-req.json")
|
||||||
require.NoError(suite.T(), err)
|
require.NoError(suite.T(), err)
|
||||||
|
|
||||||
@@ -219,10 +228,13 @@ func (suite *ServerTestSuite) TestPopulateMultiple() {
|
|||||||
mockCtrl := gomock.NewController(suite.T())
|
mockCtrl := gomock.NewController(suite.T())
|
||||||
defer mockCtrl.Finish()
|
defer mockCtrl.Finish()
|
||||||
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
|
||||||
//fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return("testdata/scrutiny_test.db")
|
//fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return("testdata/scrutiny_test.db")
|
||||||
fakeConfig.EXPECT().GetStringSlice("notify.urls").Return([]string{}).AnyTimes()
|
fakeConfig.EXPECT().GetStringSlice("notify.urls").Return([]string{}).AnyTimes()
|
||||||
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
|
||||||
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll))
|
||||||
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth))
|
||||||
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
||||||
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
||||||
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
||||||
@@ -243,7 +255,7 @@ func (suite *ServerTestSuite) TestPopulateMultiple() {
|
|||||||
ae := web.AppEngine{
|
ae := web.AppEngine{
|
||||||
Config: fakeConfig,
|
Config: fakeConfig,
|
||||||
}
|
}
|
||||||
router := ae.Setup(logrus.New())
|
router := ae.Setup(logrus.WithField("test", suite.T().Name()))
|
||||||
devicesfile, err := os.Open("testdata/register-devices-req.json")
|
devicesfile, err := os.Open("testdata/register-devices-req.json")
|
||||||
require.NoError(suite.T(), err)
|
require.NoError(suite.T(), err)
|
||||||
|
|
||||||
@@ -319,6 +331,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_WebhookFailure() {
|
|||||||
mockCtrl := gomock.NewController(suite.T())
|
mockCtrl := gomock.NewController(suite.T())
|
||||||
defer mockCtrl.Finish()
|
defer mockCtrl.Finish()
|
||||||
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
|
||||||
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
||||||
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
||||||
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
||||||
@@ -330,8 +344,9 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_WebhookFailure() {
|
|||||||
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
|
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
|
||||||
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"https://unroutable.domain.example.asdfghj"})
|
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"https://unroutable.domain.example.asdfghj"})
|
||||||
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
|
||||||
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll))
|
||||||
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth))
|
||||||
|
|
||||||
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
|
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
|
||||||
// when running test suite in github actions, we run an influxdb service as a sidecar.
|
// when running test suite in github actions, we run an influxdb service as a sidecar.
|
||||||
@@ -343,7 +358,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_WebhookFailure() {
|
|||||||
ae := web.AppEngine{
|
ae := web.AppEngine{
|
||||||
Config: fakeConfig,
|
Config: fakeConfig,
|
||||||
}
|
}
|
||||||
router := ae.Setup(logrus.New())
|
router := ae.Setup(logrus.WithField("test", suite.T().Name()))
|
||||||
|
|
||||||
//test
|
//test
|
||||||
wr := httptest.NewRecorder()
|
wr := httptest.NewRecorder()
|
||||||
@@ -361,6 +376,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptFailure() {
|
|||||||
mockCtrl := gomock.NewController(suite.T())
|
mockCtrl := gomock.NewController(suite.T())
|
||||||
defer mockCtrl.Finish()
|
defer mockCtrl.Finish()
|
||||||
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
|
||||||
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
||||||
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
||||||
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
||||||
@@ -372,8 +389,9 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptFailure() {
|
|||||||
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
|
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
|
||||||
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"script:///missing/path/on/disk"})
|
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"script:///missing/path/on/disk"})
|
||||||
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
|
||||||
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll))
|
||||||
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth))
|
||||||
|
|
||||||
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
|
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
|
||||||
// when running test suite in github actions, we run an influxdb service as a sidecar.
|
// when running test suite in github actions, we run an influxdb service as a sidecar.
|
||||||
@@ -385,7 +403,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptFailure() {
|
|||||||
ae := web.AppEngine{
|
ae := web.AppEngine{
|
||||||
Config: fakeConfig,
|
Config: fakeConfig,
|
||||||
}
|
}
|
||||||
router := ae.Setup(logrus.New())
|
router := ae.Setup(logrus.WithField("test", suite.T().Name()))
|
||||||
|
|
||||||
//test
|
//test
|
||||||
wr := httptest.NewRecorder()
|
wr := httptest.NewRecorder()
|
||||||
@@ -403,6 +421,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptSuccess() {
|
|||||||
mockCtrl := gomock.NewController(suite.T())
|
mockCtrl := gomock.NewController(suite.T())
|
||||||
defer mockCtrl.Finish()
|
defer mockCtrl.Finish()
|
||||||
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
|
||||||
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
||||||
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
||||||
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
||||||
@@ -414,8 +434,9 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptSuccess() {
|
|||||||
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
|
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
|
||||||
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"script:///usr/bin/env"})
|
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"script:///usr/bin/env"})
|
||||||
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
|
||||||
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll))
|
||||||
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth))
|
||||||
|
|
||||||
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
|
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
|
||||||
// when running test suite in github actions, we run an influxdb service as a sidecar.
|
// when running test suite in github actions, we run an influxdb service as a sidecar.
|
||||||
@@ -427,7 +448,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ScriptSuccess() {
|
|||||||
ae := web.AppEngine{
|
ae := web.AppEngine{
|
||||||
Config: fakeConfig,
|
Config: fakeConfig,
|
||||||
}
|
}
|
||||||
router := ae.Setup(logrus.New())
|
router := ae.Setup(logrus.WithField("test", suite.T().Name()))
|
||||||
|
|
||||||
//test
|
//test
|
||||||
wr := httptest.NewRecorder()
|
wr := httptest.NewRecorder()
|
||||||
@@ -445,6 +466,8 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ShoutrrrFailure() {
|
|||||||
mockCtrl := gomock.NewController(suite.T())
|
mockCtrl := gomock.NewController(suite.T())
|
||||||
defer mockCtrl.Finish()
|
defer mockCtrl.Finish()
|
||||||
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
|
||||||
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
||||||
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
||||||
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
||||||
@@ -456,8 +479,9 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ShoutrrrFailure() {
|
|||||||
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
|
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
|
||||||
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"discord://invalidtoken@channel"})
|
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{"discord://invalidtoken@channel"})
|
||||||
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
|
||||||
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll))
|
||||||
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth))
|
||||||
|
|
||||||
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
|
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
|
||||||
// when running test suite in github actions, we run an influxdb service as a sidecar.
|
// when running test suite in github actions, we run an influxdb service as a sidecar.
|
||||||
@@ -468,7 +492,7 @@ func (suite *ServerTestSuite) TestSendTestNotificationRoute_ShoutrrrFailure() {
|
|||||||
ae := web.AppEngine{
|
ae := web.AppEngine{
|
||||||
Config: fakeConfig,
|
Config: fakeConfig,
|
||||||
}
|
}
|
||||||
router := ae.Setup(logrus.New())
|
router := ae.Setup(logrus.WithField("test", suite.T().Name()))
|
||||||
|
|
||||||
//test
|
//test
|
||||||
wr := httptest.NewRecorder()
|
wr := httptest.NewRecorder()
|
||||||
@@ -486,6 +510,8 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
|
|||||||
mockCtrl := gomock.NewController(suite.T())
|
mockCtrl := gomock.NewController(suite.T())
|
||||||
defer mockCtrl.Finish()
|
defer mockCtrl.Finish()
|
||||||
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
fakeConfig := mock_config.NewMockInterface(mockCtrl)
|
||||||
|
fakeConfig.EXPECT().SetDefault(gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
fakeConfig.EXPECT().UnmarshalKey(gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
|
||||||
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
fakeConfig.EXPECT().GetString("web.database.location").AnyTimes().Return(path.Join(parentPath, "scrutiny_test.db"))
|
||||||
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
fakeConfig.EXPECT().GetString("web.src.frontend.path").AnyTimes().Return(parentPath)
|
||||||
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
fakeConfig.EXPECT().GetString("web.listen.basepath").Return(suite.Basepath).AnyTimes()
|
||||||
@@ -497,8 +523,10 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
|
|||||||
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
|
||||||
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
|
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
|
||||||
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{})
|
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{})
|
||||||
fakeConfig.EXPECT().GetString("notify.level").AnyTimes().Return(pkg.NotifyLevelFail)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.notify_level", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsNotifyLevelFail))
|
||||||
fakeConfig.EXPECT().GetString("notify.filter_attributes").AnyTimes().Return(pkg.NotifyFilterAttributesAll)
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusFilterAttributesAll))
|
||||||
|
fakeConfig.EXPECT().GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY)).AnyTimes().Return(int(pkg.MetricsStatusThresholdBoth))
|
||||||
|
|
||||||
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
|
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
|
||||||
// when running test suite in github actions, we run an influxdb service as a sidecar.
|
// when running test suite in github actions, we run an influxdb service as a sidecar.
|
||||||
fakeConfig.EXPECT().GetString("web.influxdb.host").Return("influxdb").AnyTimes()
|
fakeConfig.EXPECT().GetString("web.influxdb.host").Return("influxdb").AnyTimes()
|
||||||
@@ -509,7 +537,7 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
|
|||||||
ae := web.AppEngine{
|
ae := web.AppEngine{
|
||||||
Config: fakeConfig,
|
Config: fakeConfig,
|
||||||
}
|
}
|
||||||
router := ae.Setup(logrus.New())
|
router := ae.Setup(logrus.WithField("test", suite.T().Name()))
|
||||||
devicesfile, err := os.Open("testdata/register-devices-req-2.json")
|
devicesfile, err := os.Open("testdata/register-devices-req-2.json")
|
||||||
require.NoError(suite.T(), err)
|
require.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
|||||||
@@ -46,3 +46,5 @@ testem.log
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
|
/coverage
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"defaultConfiguration": "production",
|
||||||
"options": {
|
"options": {
|
||||||
"main": "src/test.ts",
|
"main": "src/test.ts",
|
||||||
"polyfills": "src/polyfills.ts",
|
"polyfills": "src/polyfills.ts",
|
||||||
@@ -101,10 +102,22 @@
|
|||||||
"src/favicon-32x32.png",
|
"src/favicon-32x32.png",
|
||||||
"src/assets"
|
"src/assets"
|
||||||
],
|
],
|
||||||
|
"stylePreprocessorOptions": {
|
||||||
|
"includePaths": [
|
||||||
|
"src/@treo/styles"
|
||||||
|
]
|
||||||
|
},
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles/vendors.scss",
|
||||||
|
"src/@treo/styles/main.scss",
|
||||||
|
"src/styles/styles.scss",
|
||||||
|
"src/styles/tailwind.scss"
|
||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": [],
|
||||||
|
"fileReplacements": [{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ module.exports = function (config)
|
|||||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||||
},
|
},
|
||||||
coverageIstanbulReporter: {
|
coverageIstanbulReporter: {
|
||||||
dir : require('path').join(__dirname, './coverage/treo'),
|
dir: require('path').join(__dirname, './coverage'),
|
||||||
reports : ['html', 'lcovonly', 'text-summary'],
|
reports: ['html', 'lcovonly', 'text-summary'],
|
||||||
fixWebpackSourcePaths: true
|
fixWebpackSourcePaths: true
|
||||||
},
|
},
|
||||||
reporters : ['progress', 'kjhtml'],
|
reporters : ['progress', 'kjhtml'],
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { NgModule, enableProdMode } from '@angular/core';
|
import {enableProdMode, NgModule} from '@angular/core';
|
||||||
import { BrowserModule } from '@angular/platform-browser';
|
import {BrowserModule} from '@angular/platform-browser';
|
||||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
|
||||||
import { ExtraOptions, PreloadAllModules, RouterModule } from '@angular/router';
|
import {ExtraOptions, PreloadAllModules, RouterModule} from '@angular/router';
|
||||||
import { APP_BASE_HREF } from '@angular/common';
|
import {APP_BASE_HREF} from '@angular/common';
|
||||||
import { MarkdownModule } from 'ngx-markdown';
|
import {MarkdownModule} from 'ngx-markdown';
|
||||||
import { TreoModule } from '@treo';
|
import {TreoModule} from '@treo';
|
||||||
import { TreoConfigModule } from '@treo/services/config';
|
import {ScrutinyConfigModule} from 'app/core/config/scrutiny-config.module';
|
||||||
import { TreoMockApiModule } from '@treo/lib/mock-api';
|
import {TreoMockApiModule} from '@treo/lib/mock-api';
|
||||||
import { CoreModule } from 'app/core/core.module';
|
import {CoreModule} from 'app/core/core.module';
|
||||||
import { appConfig } from 'app/core/config/app.config';
|
import {appConfig} from 'app/core/config/app.config';
|
||||||
import { mockDataServices } from 'app/data/mock';
|
import {mockDataServices} from 'app/data/mock';
|
||||||
import { LayoutModule } from 'app/layout/layout.module';
|
import {LayoutModule} from 'app/layout/layout.module';
|
||||||
import { AppComponent } from 'app/app.component';
|
import {AppComponent} from 'app/app.component';
|
||||||
import { appRoutes, getAppBaseHref } from 'app/app.routing';
|
import {appRoutes, getAppBaseHref} from 'app/app.routing';
|
||||||
|
|
||||||
const routerConfig: ExtraOptions = {
|
const routerConfig: ExtraOptions = {
|
||||||
scrollPositionRestoration: 'enabled',
|
scrollPositionRestoration: 'enabled',
|
||||||
preloadingStrategy : PreloadAllModules
|
preloadingStrategy: PreloadAllModules
|
||||||
};
|
};
|
||||||
|
|
||||||
let dev = [
|
let dev = [
|
||||||
@@ -41,7 +41,7 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
|
|
||||||
// Treo & Treo Mock API
|
// Treo & Treo Mock API
|
||||||
TreoModule,
|
TreoModule,
|
||||||
TreoConfigModule.forRoot(appConfig),
|
ScrutinyConfigModule.forRoot(appConfig),
|
||||||
...dev,
|
...dev,
|
||||||
|
|
||||||
// Core
|
// Core
|
||||||
|
|||||||
@@ -1,22 +1,58 @@
|
|||||||
import { Layout } from 'app/layout/layout.types';
|
import {Layout} from 'app/layout/layout.types';
|
||||||
|
|
||||||
// Theme type
|
// Theme type
|
||||||
export type Theme = 'light' | 'dark' | 'system';
|
export type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
// Device title to display on the dashboard
|
||||||
|
export type DashboardDisplay = 'name' | 'serial_id' | 'uuid' | 'label'
|
||||||
|
|
||||||
|
export type DashboardSort = 'status' | 'title' | 'age'
|
||||||
|
|
||||||
|
export type TemperatureUnit = 'celsius' | 'fahrenheit'
|
||||||
|
|
||||||
|
|
||||||
|
export enum MetricsNotifyLevel {
|
||||||
|
Warn = 1,
|
||||||
|
Fail = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MetricsStatusFilterAttributes {
|
||||||
|
All = 0,
|
||||||
|
Critical = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MetricsStatusThreshold {
|
||||||
|
Smart = 1,
|
||||||
|
Scrutiny = 2,
|
||||||
|
|
||||||
|
// shortcut
|
||||||
|
Both = 3
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppConfig interface. Update this interface to strictly type your config
|
* AppConfig interface. Update this interface to strictly type your config
|
||||||
* object.
|
* object.
|
||||||
*/
|
*/
|
||||||
export interface AppConfig
|
export interface AppConfig {
|
||||||
{
|
theme?: Theme;
|
||||||
theme: Theme;
|
layout?: Layout;
|
||||||
layout: Layout;
|
|
||||||
|
|
||||||
// Dashboard options
|
// Dashboard options
|
||||||
dashboardDisplay: string;
|
dashboard_display?: DashboardDisplay;
|
||||||
dashboardSort: string;
|
dashboard_sort?: DashboardSort;
|
||||||
|
|
||||||
|
temperature_unit?: TemperatureUnit;
|
||||||
|
|
||||||
|
file_size_si_units?: boolean;
|
||||||
|
|
||||||
|
// Settings from Scrutiny API
|
||||||
|
|
||||||
|
metrics?: {
|
||||||
|
notify_level?: MetricsNotifyLevel
|
||||||
|
status_filter_attributes?: MetricsStatusFilterAttributes
|
||||||
|
status_threshold?: MetricsStatusThreshold
|
||||||
|
}
|
||||||
|
|
||||||
temperatureUnit: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,12 +64,19 @@ export interface AppConfig
|
|||||||
* "ConfigService".
|
* "ConfigService".
|
||||||
*/
|
*/
|
||||||
export const appConfig: AppConfig = {
|
export const appConfig: AppConfig = {
|
||||||
theme : 'light',
|
theme: 'light',
|
||||||
layout: 'material',
|
layout: 'material',
|
||||||
|
|
||||||
dashboardDisplay: 'name',
|
dashboard_display: 'name',
|
||||||
dashboardSort: 'status',
|
dashboard_sort: 'status',
|
||||||
|
|
||||||
temperatureUnit: 'celsius',
|
temperature_unit: 'celsius',
|
||||||
|
file_size_si_units: false,
|
||||||
|
|
||||||
|
metrics: {
|
||||||
|
notify_level: MetricsNotifyLevel.Fail,
|
||||||
|
status_filter_attributes: MetricsStatusFilterAttributes.All,
|
||||||
|
status_threshold: MetricsStatusThreshold.Both
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {ModuleWithProviders, NgModule} from '@angular/core';
|
||||||
|
import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service';
|
||||||
|
import {TREO_APP_CONFIG} from '@treo/services/config/config.constants';
|
||||||
|
|
||||||
|
@NgModule()
|
||||||
|
export class ScrutinyConfigModule {
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param {ScrutinyConfigService} _scrutinyConfigService
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _scrutinyConfigService: ScrutinyConfigService
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* forRoot method for setting user configuration
|
||||||
|
*
|
||||||
|
* @param config
|
||||||
|
*/
|
||||||
|
static forRoot(config: any): ModuleWithProviders {
|
||||||
|
return {
|
||||||
|
ngModule: ScrutinyConfigModule,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TREO_APP_CONFIG,
|
||||||
|
useValue: config
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {Inject, Injectable} from '@angular/core';
|
||||||
|
import {HttpClient} from '@angular/common/http';
|
||||||
|
import {TREO_APP_CONFIG} from '@treo/services/config/config.constants';
|
||||||
|
import {BehaviorSubject, Observable} from 'rxjs';
|
||||||
|
import {getBasePath} from '../../app.routing';
|
||||||
|
import {map, tap} from 'rxjs/operators';
|
||||||
|
import {AppConfig} from './app.config';
|
||||||
|
import {merge} from 'lodash';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ScrutinyConfigService {
|
||||||
|
// Private
|
||||||
|
private _config: BehaviorSubject<AppConfig>;
|
||||||
|
private _defaultConfig: AppConfig;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private _httpClient: HttpClient,
|
||||||
|
@Inject(TREO_APP_CONFIG) defaultConfig: AppConfig
|
||||||
|
) {
|
||||||
|
// Set the private defaults
|
||||||
|
this._defaultConfig = defaultConfig
|
||||||
|
this._config = new BehaviorSubject(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Accessors
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter & getter for config
|
||||||
|
*/
|
||||||
|
set config(value: AppConfig) {
|
||||||
|
// get the current config, merge the new values, and then submit. (setTheme only sets a single key, not the whole obj)
|
||||||
|
const mergedSettings = merge({}, this._config.getValue(), value);
|
||||||
|
|
||||||
|
console.log('saving settings...', mergedSettings)
|
||||||
|
this._httpClient.post(getBasePath() + '/api/settings', mergedSettings).pipe(
|
||||||
|
map((response: any) => {
|
||||||
|
console.log('settings resp')
|
||||||
|
return response.settings
|
||||||
|
}),
|
||||||
|
tap((settings: AppConfig) => {
|
||||||
|
this._config.next(settings);
|
||||||
|
return settings
|
||||||
|
})
|
||||||
|
).subscribe(resp => {
|
||||||
|
console.log('updated settings', resp)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get config$(): Observable<AppConfig> {
|
||||||
|
if (this._config.getValue()) {
|
||||||
|
console.log('using cached settings:', this._config.getValue())
|
||||||
|
return this._config.asObservable()
|
||||||
|
} else {
|
||||||
|
console.log('retrieving settings')
|
||||||
|
return this._httpClient.get(getBasePath() + '/api/settings').pipe(
|
||||||
|
map((response: any) => {
|
||||||
|
return response.settings
|
||||||
|
}),
|
||||||
|
tap((settings: AppConfig) => {
|
||||||
|
this._config.next(settings);
|
||||||
|
return this._config.asObservable()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the config to the default
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
// Set the config
|
||||||
|
this.config = this._defaultConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import {DeviceModel} from 'app/core/models/device-model';
|
||||||
|
import {SmartModel} from 'app/core/models/measurements/smart-model';
|
||||||
|
import {AttributeMetadataModel} from 'app/core/models/thresholds/attribute-metadata-model';
|
||||||
|
|
||||||
|
// maps to webapp/backend/pkg/models/device_summary.go
|
||||||
|
export interface DeviceDetailsResponseWrapper {
|
||||||
|
success: boolean;
|
||||||
|
errors?: any[];
|
||||||
|
data: {
|
||||||
|
device: DeviceModel;
|
||||||
|
smart_results: SmartModel[];
|
||||||
|
},
|
||||||
|
metadata: { [key: string]: AttributeMetadataModel } | { [key: number]: AttributeMetadataModel };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// maps to webapp/backend/pkg/models/device.go
|
||||||
|
export interface DeviceModel {
|
||||||
|
wwn: string;
|
||||||
|
device_name?: string;
|
||||||
|
device_uuid?: string;
|
||||||
|
device_serial_id?: string;
|
||||||
|
device_label?: string;
|
||||||
|
|
||||||
|
manufacturer: string;
|
||||||
|
model_name: string;
|
||||||
|
interface_type: string;
|
||||||
|
interface_speed: string;
|
||||||
|
serial_number: string;
|
||||||
|
firmware: string;
|
||||||
|
rotational_speed: number;
|
||||||
|
capacity: number;
|
||||||
|
form_factor: string;
|
||||||
|
smart_support: boolean;
|
||||||
|
device_protocol: string;
|
||||||
|
device_type: string;
|
||||||
|
|
||||||
|
label: string;
|
||||||
|
host_id: string;
|
||||||
|
|
||||||
|
device_status: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {DeviceModel} from 'app/core/models/device-model';
|
||||||
|
import {SmartTemperatureModel} from 'app/core/models/measurements/smart-temperature-model';
|
||||||
|
|
||||||
|
// maps to webapp/backend/pkg/models/device_summary.go
|
||||||
|
export interface DeviceSummaryModel {
|
||||||
|
device: DeviceModel;
|
||||||
|
smart?: SmartSummary;
|
||||||
|
temp_history?: SmartTemperatureModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmartSummary {
|
||||||
|
collector_date?: string,
|
||||||
|
temp?: number
|
||||||
|
power_on_hours?: number
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
|
||||||
|
|
||||||
|
// maps to webapp/backend/pkg/models/device_summary.go
|
||||||
|
export interface DeviceSummaryResponseWrapper {
|
||||||
|
success: boolean;
|
||||||
|
errors: any[];
|
||||||
|
data: {
|
||||||
|
summary: { [key: string]: DeviceSummaryModel }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {SmartTemperatureModel} from './measurements/smart-temperature-model';
|
||||||
|
|
||||||
|
export interface DeviceSummaryTempResponseWrapper {
|
||||||
|
success: boolean;
|
||||||
|
errors: any[];
|
||||||
|
data: {
|
||||||
|
temp_history: { [key: string]: SmartTemperatureModel[]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// maps to webapp/backend/pkg/models/measurements/smart_ata_attribute.go
|
||||||
|
// maps to webapp/backend/pkg/models/measurements/smart_nvme_attribute.go
|
||||||
|
// maps to webapp/backend/pkg/models/measurements/smart_scsi_attribute.go
|
||||||
|
export interface SmartAttributeModel {
|
||||||
|
attribute_id: number | string
|
||||||
|
value: number
|
||||||
|
thresh: number
|
||||||
|
worst?: number
|
||||||
|
raw_value?: number
|
||||||
|
raw_string?: string
|
||||||
|
when_failed?: string
|
||||||
|
|
||||||
|
transformed_value: number
|
||||||
|
status: number
|
||||||
|
status_reason?: string
|
||||||
|
failure_rate?: number
|
||||||
|
|
||||||
|
chartData?: any[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// maps to webapp/backend/pkg/models/measurements/smart.go
|
||||||
|
import {SmartAttributeModel} from './smart-attribute-model';
|
||||||
|
|
||||||
|
export interface SmartModel {
|
||||||
|
date: string;
|
||||||
|
device_wwn: string;
|
||||||
|
device_protocol: string;
|
||||||
|
|
||||||
|
temp: number;
|
||||||
|
power_on_hours: number;
|
||||||
|
power_cycle_count: number
|
||||||
|
attrs: { [key: string]: SmartAttributeModel }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// maps to webapp/backend/pkg/models/measurements/smart_temperature.go
|
||||||
|
export interface SmartTemperatureModel {
|
||||||
|
date: string;
|
||||||
|
temp: number;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// map to webapp/backend/pkg/thresholds/ata_attribute_metadata.go
|
||||||
|
// map to webapp/backend/pkg/thresholds/nvme_attribute_metadata.go
|
||||||
|
// map to webapp/backend/pkg/thresholds/scsi_attribute_metadata.go
|
||||||
|
export interface AttributeMetadataModel {
|
||||||
|
display_name: string
|
||||||
|
ideal: string
|
||||||
|
critical: boolean
|
||||||
|
description: string
|
||||||
|
|
||||||
|
transform_value_unit?: string
|
||||||
|
observed_thresholds?: any[]
|
||||||
|
display_type: string
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+57
-18
@@ -1,25 +1,64 @@
|
|||||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
|
||||||
|
|
||||||
|
import {DashboardDeviceDeleteDialogComponent} from './dashboard-device-delete-dialog.component';
|
||||||
|
import {HttpClientModule} from '@angular/common/http';
|
||||||
|
import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from '@angular/material/dialog';
|
||||||
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
|
import {MatIconModule} from '@angular/material/icon';
|
||||||
|
import {SharedModule} from '../../../shared/shared.module';
|
||||||
|
import {DashboardDeviceDeleteDialogService} from './dashboard-device-delete-dialog.service';
|
||||||
|
import {of} from 'rxjs';
|
||||||
|
|
||||||
import { DashboardDeviceDeleteDialogComponent } from './dashboard-device-delete-dialog.component';
|
|
||||||
|
|
||||||
describe('DashboardDeviceDeleteDialogComponent', () => {
|
describe('DashboardDeviceDeleteDialogComponent', () => {
|
||||||
let component: DashboardDeviceDeleteDialogComponent;
|
let component: DashboardDeviceDeleteDialogComponent;
|
||||||
let fixture: ComponentFixture<DashboardDeviceDeleteDialogComponent>;
|
let fixture: ComponentFixture<DashboardDeviceDeleteDialogComponent>;
|
||||||
|
|
||||||
beforeEach(async(() => {
|
const matDialogRefSpy = jasmine.createSpyObj('MatDialogRef', ['closeDialog', 'close']);
|
||||||
TestBed.configureTestingModule({
|
const dashboardDeviceDeleteDialogServiceSpy = jasmine.createSpyObj('DashboardDeviceDeleteDialogService', ['deleteDevice']);
|
||||||
declarations: [ DashboardDeviceDeleteDialogComponent ]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async(() => {
|
||||||
fixture = TestBed.createComponent(DashboardDeviceDeleteDialogComponent);
|
TestBed.configureTestingModule({
|
||||||
component = fixture.componentInstance;
|
imports: [
|
||||||
fixture.detectChanges();
|
HttpClientModule,
|
||||||
});
|
MatDialogModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
SharedModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{provide: MatDialogRef, useValue: matDialogRefSpy},
|
||||||
|
{provide: MAT_DIALOG_DATA, useValue: {wwn: 'test-wwn', title: 'my-test-device-title'}},
|
||||||
|
{provide: DashboardDeviceDeleteDialogService, useValue: dashboardDeviceDeleteDialogServiceSpy}
|
||||||
|
],
|
||||||
|
declarations: [DashboardDeviceDeleteDialogComponent]
|
||||||
|
})
|
||||||
|
.compileComponents()
|
||||||
|
}));
|
||||||
|
|
||||||
it('should create', () => {
|
beforeEach(() => {
|
||||||
expect(component).toBeTruthy();
|
fixture = TestBed.createComponent(DashboardDeviceDeleteDialogComponent);
|
||||||
});
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close the component if cancel is clicked', () => {
|
||||||
|
matDialogRefSpy.closeDialog.calls.reset();
|
||||||
|
matDialogRefSpy.closeDialog()
|
||||||
|
expect(matDialogRefSpy.closeDialog).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should attempt to delete device if delete is clicked', () => {
|
||||||
|
dashboardDeviceDeleteDialogServiceSpy.deleteDevice.and.returnValue(of({'success': true}));
|
||||||
|
|
||||||
|
component.onDeleteClick()
|
||||||
|
expect(dashboardDeviceDeleteDialogServiceSpy.deleteDevice).toHaveBeenCalledWith('test-wwn');
|
||||||
|
expect(dashboardDeviceDeleteDialogServiceSpy.deleteDevice.calls.count())
|
||||||
|
.withContext('one call')
|
||||||
|
.toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
import { Component, OnInit, Inject } from '@angular/core';
|
import {Component, Inject, OnInit} from '@angular/core';
|
||||||
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
|
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
|
||||||
import {DashboardDeviceDeleteDialogService} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.service';
|
import {DashboardDeviceDeleteDialogService} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.service';
|
||||||
import {Subject} from 'rxjs';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dashboard-device-delete-dialog',
|
selector: 'app-dashboard-device-delete-dialog',
|
||||||
|
|||||||
+7
-30
@@ -1,44 +1,21 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import {NgModule} from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import {RouterModule} from '@angular/router';
|
||||||
import { Overlay } from '@angular/cdk/overlay';
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
import { MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocompleteModule } from '@angular/material/autocomplete';
|
import {MatIconModule} from '@angular/material/icon';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import {SharedModule} from 'app/shared/shared.module';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { SharedModule } from 'app/shared/shared.module';
|
|
||||||
import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component'
|
import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component'
|
||||||
import { MatButtonToggleModule} from '@angular/material/button-toggle';
|
|
||||||
import {MatTabsModule} from '@angular/material/tabs';
|
|
||||||
import {MatSliderModule} from '@angular/material/slider';
|
|
||||||
import {MatSlideToggleModule} from '@angular/material/slide-toggle';
|
|
||||||
import {MatTooltipModule} from '@angular/material/tooltip';
|
|
||||||
import {dashboardRoutes} from 'app/modules/dashboard/dashboard.routing';
|
import {dashboardRoutes} from 'app/modules/dashboard/dashboard.routing';
|
||||||
import {MatDividerModule} from '@angular/material/divider';
|
import {MatDialogModule} from '@angular/material/dialog';
|
||||||
import {MatMenuModule} from '@angular/material/menu';
|
|
||||||
import {MatProgressBarModule} from '@angular/material/progress-bar';
|
|
||||||
import {MatSortModule} from '@angular/material/sort';
|
|
||||||
import {MatTableModule} from '@angular/material/table';
|
|
||||||
import {NgApexchartsModule} from 'ng-apexcharts';
|
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
DashboardDeviceDeleteDialogComponent
|
DashboardDeviceDeleteDialogComponent
|
||||||
],
|
],
|
||||||
imports : [
|
imports: [
|
||||||
RouterModule.forChild([]),
|
RouterModule.forChild([]),
|
||||||
RouterModule.forChild(dashboardRoutes),
|
RouterModule.forChild(dashboardRoutes),
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatDividerModule,
|
|
||||||
MatTooltipModule,
|
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatMenuModule,
|
|
||||||
MatProgressBarModule,
|
|
||||||
MatSortModule,
|
|
||||||
MatTableModule,
|
|
||||||
NgApexchartsModule,
|
|
||||||
SharedModule,
|
SharedModule,
|
||||||
MatDialogModule
|
MatDialogModule
|
||||||
],
|
],
|
||||||
|
|||||||
+12
-9
@@ -1,21 +1,21 @@
|
|||||||
<div [ngClass]="{ 'border-green': deviceSummary.device.device_status == 0 && deviceSummary.smart,
|
<div [ngClass]="{ 'border-green': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'passed',
|
||||||
'border-red': deviceSummary.device.device_status != 0 }"
|
'border-red': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'failed' }"
|
||||||
class="relative flex flex-col flex-auto p-6 pr-3 pb-3 bg-card rounded border-l-4 shadow-md overflow-hidden">
|
class="relative flex flex-col flex-auto p-6 pr-3 pb-3 bg-card rounded border-l-4 shadow-md overflow-hidden">
|
||||||
<div class="absolute bottom-0 right-0 w-24 h-24 -m-6">
|
<div class="absolute bottom-0 right-0 w-24 h-24 -m-6">
|
||||||
<mat-icon class="icon-size-96 opacity-12 text-green"
|
<mat-icon class="icon-size-96 opacity-12 text-green"
|
||||||
*ngIf="deviceSummary.device.device_status == 0 && deviceSummary.smart"
|
*ngIf="deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'passed'"
|
||||||
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
|
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
|
||||||
<mat-icon class="icon-size-96 opacity-12 text-red"
|
<mat-icon class="icon-size-96 opacity-12 text-red"
|
||||||
*ngIf="deviceSummary.device.device_status != 0"
|
*ngIf="deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'failed'"
|
||||||
[svgIcon]="'heroicons_outline:exclamation-circle'"></mat-icon>
|
[svgIcon]="'heroicons_outline:exclamation-circle'"></mat-icon>
|
||||||
<mat-icon class="icon-size-96 opacity-12 text-yellow"
|
<mat-icon class="icon-size-96 opacity-12 text-yellow"
|
||||||
*ngIf="!deviceSummary.smart"
|
*ngIf="deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'unknown'"
|
||||||
[svgIcon]="'heroicons_outline:question-mark-circle'"></mat-icon>
|
[svgIcon]="'heroicons_outline:question-mark-circle'"></mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<a [routerLink]="'/device/'+ deviceSummary.device.wwn"
|
<a [routerLink]="'/device/'+ deviceSummary.device.wwn"
|
||||||
class="font-bold text-md text-secondary uppercase tracking-wider">{{deviceSummary.device | deviceTitle:config.dashboardDisplay}}</a>
|
class="font-bold text-md text-secondary uppercase tracking-wider">{{deviceSummary.device | deviceTitle:config.dashboard_display}}</a>
|
||||||
<div [ngClass]="classDeviceLastUpdatedOn(deviceSummary)" class="font-medium text-sm" *ngIf="deviceSummary.smart">
|
<div [ngClass]="classDeviceLastUpdatedOn(deviceSummary)" class="font-medium text-sm" *ngIf="deviceSummary.smart">
|
||||||
Last Updated on {{deviceSummary.smart.collector_date | date:'MMMM dd, yyyy - HH:mm' }}
|
Last Updated on {{deviceSummary.smart.collector_date | date:'MMMM dd, yyyy - HH:mm' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -46,17 +46,20 @@
|
|||||||
<div class="flex flex-row flex-wrap mt-4 -mx-6">
|
<div class="flex flex-row flex-wrap mt-4 -mx-6">
|
||||||
<div class="flex flex-col mx-6 my-3 xs:w-full">
|
<div class="flex flex-col mx-6 my-3 xs:w-full">
|
||||||
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Status</div>
|
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Status</div>
|
||||||
<div class="mt-2 font-medium text-3xl leading-none" *ngIf="deviceSummary.smart?.collector_date; else unknownStatus">{{ deviceStatusString(deviceSummary.device.device_status) | titlecase}}</div>
|
<div class="mt-2 font-medium text-3xl leading-none"
|
||||||
|
*ngIf="deviceSummary.smart?.collector_date; else unknownStatus">{{ deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) | titlecase}}</div>
|
||||||
<ng-template #unknownStatus><div class="mt-2 font-medium text-3xl leading-none">No Data</div></ng-template>
|
<ng-template #unknownStatus><div class="mt-2 font-medium text-3xl leading-none">No Data</div></ng-template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col mx-6 my-3 xs:w-full">
|
<div class="flex flex-col mx-6 my-3 xs:w-full">
|
||||||
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Temperature</div>
|
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Temperature</div>
|
||||||
<div class="mt-2 font-medium text-3xl leading-none" *ngIf="deviceSummary.smart?.collector_date; else unknownTemp">{{ deviceSummary.smart?.temp | temperature:config.temperatureUnit:true }}</div>
|
<div class="mt-2 font-medium text-3xl leading-none"
|
||||||
|
*ngIf="deviceSummary.smart?.collector_date; else unknownTemp">{{ deviceSummary.smart?.temp | temperature:config.temperature_unit:true }}</div>
|
||||||
<ng-template #unknownTemp><div class="mt-2 font-medium text-3xl leading-none">--</div></ng-template>
|
<ng-template #unknownTemp><div class="mt-2 font-medium text-3xl leading-none">--</div></ng-template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col mx-6 my-3 xs:w-full">
|
<div class="flex flex-col mx-6 my-3 xs:w-full">
|
||||||
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Capacity</div>
|
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Capacity</div>
|
||||||
<div class="mt-2 font-medium text-3xl leading-none">{{ deviceSummary.device.capacity | fileSize}}</div>
|
<div
|
||||||
|
class="mt-2 font-medium text-3xl leading-none">{{ deviceSummary.device.capacity | fileSize:config.file_size_si_units}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col mx-6 my-3 xs:w-full">
|
<div class="flex flex-col mx-6 my-3 xs:w-full">
|
||||||
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Powered On</div>
|
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Powered On</div>
|
||||||
|
|||||||
+152
-19
@@ -1,25 +1,158 @@
|
|||||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
|
||||||
|
|
||||||
import { DashboardDeviceComponent } from './dashboard-device.component';
|
import {DashboardDeviceComponent} from './dashboard-device.component';
|
||||||
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
|
import {MatIconModule} from '@angular/material/icon';
|
||||||
|
import {SharedModule} from 'app/shared/shared.module';
|
||||||
|
import {MatMenuModule} from '@angular/material/menu';
|
||||||
|
import {TREO_APP_CONFIG} from '@treo/services/config/config.constants';
|
||||||
|
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
|
||||||
|
import * as moment from 'moment';
|
||||||
|
import {HttpClientTestingModule} from '@angular/common/http/testing';
|
||||||
|
import {HttpClient} from '@angular/common/http';
|
||||||
|
import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service';
|
||||||
|
import {of} from 'rxjs';
|
||||||
|
import {MetricsStatusThreshold} from 'app/core/config/app.config';
|
||||||
|
|
||||||
describe('DashboardDeviceComponent', () => {
|
describe('DashboardDeviceComponent', () => {
|
||||||
let component: DashboardDeviceComponent;
|
let component: DashboardDeviceComponent;
|
||||||
let fixture: ComponentFixture<DashboardDeviceComponent>;
|
let fixture: ComponentFixture<DashboardDeviceComponent>;
|
||||||
|
|
||||||
beforeEach(async(() => {
|
const matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']);
|
||||||
TestBed.configureTestingModule({
|
// const configServiceSpy = jasmine.createSpyObj('ScrutinyConfigService', ['config$']);
|
||||||
declarations: [ DashboardDeviceComponent ]
|
let configService: ScrutinyConfigService;
|
||||||
|
let httpClientSpy: jasmine.SpyObj<HttpClient>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
|
||||||
|
httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
|
||||||
|
configService = new ScrutinyConfigService(httpClientSpy, {});
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatMenuModule,
|
||||||
|
SharedModule,
|
||||||
|
HttpClientTestingModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{provide: MatDialog, useValue: matDialogSpy},
|
||||||
|
{provide: TREO_APP_CONFIG, useValue: {dashboard_display: 'name', metrics: {status_threshold: 3}}},
|
||||||
|
{provide: ScrutinyConfigService, useValue: configService}
|
||||||
|
],
|
||||||
|
declarations: [DashboardDeviceComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// configServiceSpy.config$.and.returnValue(of({'success': true}));
|
||||||
|
fixture = TestBed.createComponent(DashboardDeviceComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#classDeviceLastUpdatedOn()', () => {
|
||||||
|
|
||||||
|
it('if non-zero device status, should be red', () => {
|
||||||
|
httpClientSpy.get.and.returnValue(of({
|
||||||
|
settings: {
|
||||||
|
metrics: {
|
||||||
|
status_threshold: MetricsStatusThreshold.Both,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
component.ngOnInit()
|
||||||
|
// component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel
|
||||||
|
expect(component.classDeviceLastUpdatedOn({
|
||||||
|
device: {
|
||||||
|
device_status: 2,
|
||||||
|
},
|
||||||
|
smart: {
|
||||||
|
collector_date: moment().subtract(13, 'days').toISOString()
|
||||||
|
},
|
||||||
|
} as DeviceSummaryModel)).toBe('text-red')
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if non-zero device status, should be red', () => {
|
||||||
|
httpClientSpy.get.and.returnValue(of({
|
||||||
|
settings: {
|
||||||
|
metrics: {
|
||||||
|
status_threshold: MetricsStatusThreshold.Both,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.classDeviceLastUpdatedOn({
|
||||||
|
device: {
|
||||||
|
device_status: 2
|
||||||
|
},
|
||||||
|
smart: {
|
||||||
|
collector_date: moment().subtract(13, 'days').toISOString()
|
||||||
|
},
|
||||||
|
} as DeviceSummaryModel)).toBe('text-red')
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if healthy device status and updated in the last two weeks, should be green', () => {
|
||||||
|
httpClientSpy.get.and.returnValue(of({
|
||||||
|
settings: {
|
||||||
|
metrics: {
|
||||||
|
status_threshold: MetricsStatusThreshold.Both,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.classDeviceLastUpdatedOn({
|
||||||
|
device: {
|
||||||
|
device_status: 0
|
||||||
|
},
|
||||||
|
smart: {
|
||||||
|
collector_date: moment().subtract(13, 'days').toISOString()
|
||||||
|
}
|
||||||
|
} as DeviceSummaryModel)).toBe('text-green')
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if healthy device status and updated more than two weeks ago, but less than 1 month, should be yellow', () => {
|
||||||
|
httpClientSpy.get.and.returnValue(of({
|
||||||
|
settings: {
|
||||||
|
metrics: {
|
||||||
|
status_threshold: MetricsStatusThreshold.Both,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.classDeviceLastUpdatedOn({
|
||||||
|
device: {
|
||||||
|
device_status: 0
|
||||||
|
},
|
||||||
|
smart: {
|
||||||
|
collector_date: moment().subtract(3, 'weeks').toISOString()
|
||||||
|
}
|
||||||
|
} as DeviceSummaryModel)).toBe('text-yellow')
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if healthy device status and updated more 1 month ago, should be red', () => {
|
||||||
|
httpClientSpy.get.and.returnValue(of({
|
||||||
|
settings: {
|
||||||
|
metrics: {
|
||||||
|
status_threshold: MetricsStatusThreshold.Both,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.classDeviceLastUpdatedOn({
|
||||||
|
device: {
|
||||||
|
device_status: 0
|
||||||
|
},
|
||||||
|
smart: {
|
||||||
|
collector_date: moment().subtract(5, 'weeks').toISOString()
|
||||||
|
}
|
||||||
|
} as DeviceSummaryModel)).toBe('text-red')
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.compileComponents();
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(DashboardDeviceComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-27
@@ -1,29 +1,32 @@
|
|||||||
import { Component, Input, Output, OnInit, EventEmitter} from '@angular/core';
|
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
|
||||||
import * as moment from 'moment';
|
import * as moment from 'moment';
|
||||||
import {takeUntil} from 'rxjs/operators';
|
import {takeUntil} from 'rxjs/operators';
|
||||||
import {AppConfig} from 'app/core/config/app.config';
|
import {AppConfig} from 'app/core/config/app.config';
|
||||||
import {TreoConfigService} from '@treo/services/config';
|
import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service';
|
||||||
import {Subject} from 'rxjs';
|
import {Subject} from 'rxjs';
|
||||||
import humanizeDuration from 'humanize-duration'
|
import humanizeDuration from 'humanize-duration'
|
||||||
import {MatDialog} from '@angular/material/dialog';
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component';
|
import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component';
|
||||||
import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
|
import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
|
||||||
|
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
|
||||||
|
import {DeviceStatusPipe} from 'app/shared/device-status.pipe';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dashboard-device',
|
selector: 'app-dashboard-device',
|
||||||
templateUrl: './dashboard-device.component.html',
|
templateUrl: './dashboard-device.component.html',
|
||||||
styleUrls: ['./dashboard-device.component.scss']
|
styleUrls: ['./dashboard-device.component.scss']
|
||||||
})
|
})
|
||||||
export class DashboardDeviceComponent implements OnInit {
|
export class DashboardDeviceComponent implements OnInit {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _configService: TreoConfigService,
|
private _configService: ScrutinyConfigService,
|
||||||
public dialog: MatDialog,
|
public dialog: MatDialog,
|
||||||
) {
|
) {
|
||||||
// Set the private defaults
|
// Set the private defaults
|
||||||
this._unsubscribeAll = new Subject();
|
this._unsubscribeAll = new Subject();
|
||||||
}
|
}
|
||||||
@Input() deviceSummary: any;
|
|
||||||
|
@Input() deviceSummary: DeviceSummaryModel;
|
||||||
@Input() deviceWWN: string;
|
@Input() deviceWWN: string;
|
||||||
@Output() deviceDeleted = new EventEmitter<string>();
|
@Output() deviceDeleted = new EventEmitter<string>();
|
||||||
|
|
||||||
@@ -33,6 +36,8 @@ export class DashboardDeviceComponent implements OnInit {
|
|||||||
|
|
||||||
readonly humanizeDuration = humanizeDuration;
|
readonly humanizeDuration = humanizeDuration;
|
||||||
|
|
||||||
|
deviceStatusForModelWithThreshold = DeviceStatusPipe.deviceStatusForModelWithThreshold
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Subscribe to config changes
|
// Subscribe to config changes
|
||||||
this._configService.config$
|
this._configService.config$
|
||||||
@@ -47,45 +52,38 @@ export class DashboardDeviceComponent implements OnInit {
|
|||||||
// @ Public methods
|
// @ Public methods
|
||||||
// -----------------------------------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
classDeviceLastUpdatedOn(deviceSummary): string {
|
classDeviceLastUpdatedOn(deviceSummary: DeviceSummaryModel): string {
|
||||||
if (deviceSummary.device.device_status !== 0) {
|
const deviceStatus = DeviceStatusPipe.deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, this.config.metrics.status_threshold)
|
||||||
|
if (deviceStatus === 'failed') {
|
||||||
return 'text-red' // if the device has failed, always highlight in red
|
return 'text-red' // if the device has failed, always highlight in red
|
||||||
} else if(deviceSummary.device.device_status === 0 && deviceSummary.smart){
|
} else if (deviceStatus === 'passed') {
|
||||||
if(moment().subtract(14, 'd').isBefore(deviceSummary.smart.collector_date)){
|
if (moment().subtract(14, 'days').isBefore(deviceSummary.smart.collector_date)) {
|
||||||
// this device was updated in the last 2 weeks.
|
// this device was updated in the last 2 weeks.
|
||||||
return 'text-green'
|
return 'text-green'
|
||||||
} else if(moment().subtract(1, 'm').isBefore(deviceSummary.smart.collector_date)){
|
} else if (moment().subtract(1, 'months').isBefore(deviceSummary.smart.collector_date)) {
|
||||||
// this device was updated in the last month
|
// this device was updated in the last month
|
||||||
return 'text-yellow'
|
return 'text-yellow'
|
||||||
} else{
|
} else {
|
||||||
// last updated more than a month ago.
|
// last updated more than a month ago.
|
||||||
return 'text-red'
|
return 'text-red'
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceStatusString(deviceStatus): string {
|
|
||||||
if(deviceStatus === 0){
|
|
||||||
return 'passed'
|
|
||||||
} else {
|
|
||||||
return 'failed'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
openDeleteDialog(): void {
|
openDeleteDialog(): void {
|
||||||
const dialogRef = this.dialog.open(DashboardDeviceDeleteDialogComponent, {
|
const dialogRef = this.dialog.open(DashboardDeviceDeleteDialogComponent, {
|
||||||
// width: '250px',
|
// width: '250px',
|
||||||
data: {wwn: this.deviceWWN, title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboardDisplay)}
|
data: {
|
||||||
|
wwn: this.deviceWWN,
|
||||||
|
title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboard_display)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dialogRef.afterClosed().subscribe(result => {
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
console.log('The dialog was closed', result);
|
console.log('The dialog was closed', result);
|
||||||
if(result.success){
|
if (result.success) {
|
||||||
this.deviceDeleted.emit(this.deviceWWN)
|
this.deviceDeleted.emit(this.deviceWWN)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,53 +1,30 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import {NgModule} from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import {RouterModule} from '@angular/router';
|
||||||
import { Overlay } from '@angular/cdk/overlay';
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
import { MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocompleteModule } from '@angular/material/autocomplete';
|
import {MatIconModule} from '@angular/material/icon';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import {SharedModule} from 'app/shared/shared.module';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { SharedModule } from 'app/shared/shared.module';
|
|
||||||
import {DashboardDeviceComponent} from 'app/layout/common/dashboard-device/dashboard-device.component'
|
import {DashboardDeviceComponent} from 'app/layout/common/dashboard-device/dashboard-device.component'
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { MatButtonToggleModule} from '@angular/material/button-toggle';
|
|
||||||
import {MatTabsModule} from '@angular/material/tabs';
|
|
||||||
import {MatSliderModule} from '@angular/material/slider';
|
|
||||||
import {MatSlideToggleModule} from '@angular/material/slide-toggle';
|
|
||||||
import {MatTooltipModule} from '@angular/material/tooltip';
|
|
||||||
import {dashboardRoutes} from '../../../modules/dashboard/dashboard.routing';
|
import {dashboardRoutes} from '../../../modules/dashboard/dashboard.routing';
|
||||||
import {MatDividerModule} from '@angular/material/divider';
|
|
||||||
import {MatMenuModule} from '@angular/material/menu';
|
import {MatMenuModule} from '@angular/material/menu';
|
||||||
import {MatProgressBarModule} from '@angular/material/progress-bar';
|
|
||||||
import {MatSortModule} from '@angular/material/sort';
|
|
||||||
import {MatTableModule} from '@angular/material/table';
|
|
||||||
import {NgApexchartsModule} from 'ng-apexcharts';
|
|
||||||
import {DashboardDeviceDeleteDialogModule} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.module';
|
import {DashboardDeviceDeleteDialogModule} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
DashboardDeviceComponent
|
DashboardDeviceComponent
|
||||||
],
|
],
|
||||||
imports : [
|
imports: [
|
||||||
RouterModule.forChild([]),
|
RouterModule.forChild([]),
|
||||||
RouterModule.forChild(dashboardRoutes),
|
RouterModule.forChild(dashboardRoutes),
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatDividerModule,
|
|
||||||
MatTooltipModule,
|
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatProgressBarModule,
|
|
||||||
MatSortModule,
|
|
||||||
MatTableModule,
|
|
||||||
NgApexchartsModule,
|
|
||||||
SharedModule,
|
SharedModule,
|
||||||
DashboardDeviceDeleteDialogModule
|
DashboardDeviceDeleteDialogModule
|
||||||
],
|
],
|
||||||
exports : [
|
exports: [
|
||||||
DashboardDeviceComponent,
|
DashboardDeviceComponent,
|
||||||
],
|
],
|
||||||
providers : []
|
providers: []
|
||||||
})
|
})
|
||||||
export class DashboardDeviceModule
|
export class DashboardDeviceModule {
|
||||||
{
|
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-54
@@ -37,69 +37,41 @@
|
|||||||
|
|
||||||
<div class="flex flex-col mt-5 gt-md:flex-row">
|
<div class="flex flex-col mt-5 gt-md:flex-row">
|
||||||
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
|
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
|
||||||
<mat-label>Temperature Display Unit</mat-label>
|
<mat-label>Temperature</mat-label>
|
||||||
<mat-select [(ngModel)]="temperatureUnit">
|
<mat-select [(ngModel)]="temperatureUnit">
|
||||||
<mat-option value="celsius">Celsius</mat-option>
|
<mat-option value="celsius">Celsius</mat-option>
|
||||||
<mat-option value="fahrenheit">Fahrenheit</mat-option>
|
<mat-option value="fahrenheit">Fahrenheit</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
|
||||||
|
<mat-label>File Size</mat-label>
|
||||||
|
<mat-select [(ngModel)]="fileSizeSIUnits">
|
||||||
|
<mat-option [value]=true>SI Units (GB)</mat-option>
|
||||||
|
<mat-option [value]=false>Binary Units (GiB)</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex flex-col mt-5 gt-md:flex-row">
|
||||||
<mat-tab-group mat-align-tabs="start">
|
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
|
||||||
<mat-tab label="Ata">
|
<mat-label>Device Status - Thresholds</mat-label>
|
||||||
|
<mat-select [(ngModel)]=statusThreshold>
|
||||||
|
<mat-option [value]=1>Smart</mat-option>
|
||||||
|
<mat-option [value]=2>Scrutiny</mat-option>
|
||||||
|
<mat-option [value]=3>Both</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div matTooltip="not yet implemented" class="flex flex-col mt-5 gt-md:flex-row">
|
<div class="flex flex-col mt-5 gt-md:flex-row">
|
||||||
<mat-form-field class="flex-auto gt-md:pr-3">
|
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
|
||||||
<mat-label class="text-hint">Critical Error Threshold</mat-label>
|
<mat-label>Notify - Filter Attributes</mat-label>
|
||||||
<input disabled matInput [value]="'10%'">
|
<mat-select [(ngModel)]=statusFilterAttributes>
|
||||||
</mat-form-field>
|
<mat-option [value]=0>All</mat-option>
|
||||||
<mat-form-field class="flex-auto gt-md:pl-3">
|
<mat-option [value]=1>Critical</mat-option>
|
||||||
<mat-label class="text-hint">Critical Warning Threshold</mat-label>
|
</mat-select>
|
||||||
<input disabled matInput>
|
</mat-form-field>
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div matTooltip="not yet implemented" class="flex flex-col gt-md:flex-row">
|
|
||||||
<mat-form-field class="flex-auto gt-md:pr-3">
|
|
||||||
<mat-label class="text-hint">Error Threshold</mat-label>
|
|
||||||
<input disabled matInput [value]="'20%'">
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field class="flex-auto gt-md:pl-3">
|
|
||||||
<mat-label class="text-hint">Warning Threshold</mat-label>
|
|
||||||
<input disabled matInput [value]="'10%'">
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</mat-tab>
|
|
||||||
<mat-tab label="NVMe">
|
|
||||||
|
|
||||||
<div matTooltip="not yet implemented" class="flex flex-col mt-5 gt-md:flex-row">
|
|
||||||
<mat-form-field class="flex-auto gt-md:pr-3">
|
|
||||||
<mat-label class="text-hint">Critical Error Threshold</mat-label>
|
|
||||||
<input disabled matInput [value]="'enabled'">
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field class="flex-auto gt-md:pl-3">
|
|
||||||
<mat-label class="text-hint">Critical Warning Threshold</mat-label>
|
|
||||||
<input disabled matInput>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</mat-tab>
|
|
||||||
<mat-tab label="SCSI">
|
|
||||||
<div matTooltip="not yet implemented" class="flex flex-col mt-5 gt-md:flex-row">
|
|
||||||
<mat-form-field class="flex-auto gt-md:pr-3">
|
|
||||||
<mat-label class="text-hint">Critical Error Threshold</mat-label>
|
|
||||||
<input disabled matInput [value]="'enabled'">
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field class="flex-auto gt-md:pl-3">
|
|
||||||
<mat-label class="text-hint">Critical Warning Threshold</mat-label>
|
|
||||||
<input disabled matInput>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</mat-tab>
|
|
||||||
</mat-tab-group>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { DashboardSettingsComponent } from './dashboard-settings.component';
|
|
||||||
|
|
||||||
describe('DashboardSettingsComponent', () => {
|
|
||||||
let component: DashboardSettingsComponent;
|
|
||||||
let fixture: ComponentFixture<DashboardSettingsComponent>;
|
|
||||||
|
|
||||||
beforeEach(async(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [ DashboardSettingsComponent ]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(DashboardSettingsComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+45
-27
@@ -1,59 +1,77 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import {Component, OnInit} from '@angular/core';
|
||||||
import {AppConfig} from 'app/core/config/app.config';
|
import {
|
||||||
import { TreoConfigService } from '@treo/services/config';
|
AppConfig,
|
||||||
|
DashboardDisplay,
|
||||||
|
DashboardSort,
|
||||||
|
MetricsStatusFilterAttributes,
|
||||||
|
MetricsStatusThreshold,
|
||||||
|
TemperatureUnit,
|
||||||
|
Theme
|
||||||
|
} from 'app/core/config/app.config';
|
||||||
|
import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service';
|
||||||
import {Subject} from 'rxjs';
|
import {Subject} from 'rxjs';
|
||||||
import {takeUntil} from 'rxjs/operators';
|
import {takeUntil} from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dashboard-settings',
|
selector: 'app-dashboard-settings',
|
||||||
templateUrl: './dashboard-settings.component.html',
|
templateUrl: './dashboard-settings.component.html',
|
||||||
styleUrls: ['./dashboard-settings.component.scss']
|
styleUrls: ['./dashboard-settings.component.scss']
|
||||||
})
|
})
|
||||||
export class DashboardSettingsComponent implements OnInit {
|
export class DashboardSettingsComponent implements OnInit {
|
||||||
|
|
||||||
dashboardDisplay: string;
|
dashboardDisplay: string;
|
||||||
dashboardSort: string;
|
dashboardSort: string;
|
||||||
temperatureUnit: string;
|
temperatureUnit: string;
|
||||||
|
fileSizeSIUnits: boolean;
|
||||||
theme: string;
|
theme: string;
|
||||||
|
statusThreshold: number;
|
||||||
|
statusFilterAttributes: number;
|
||||||
|
|
||||||
// Private
|
// Private
|
||||||
private _unsubscribeAll: Subject<any>;
|
private _unsubscribeAll: Subject<any>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _configService: TreoConfigService,
|
private _configService: ScrutinyConfigService,
|
||||||
) {
|
) {
|
||||||
// Set the private defaults
|
// Set the private defaults
|
||||||
this._unsubscribeAll = new Subject();
|
this._unsubscribeAll = new Subject();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Subscribe to config changes
|
// Subscribe to config changes
|
||||||
this._configService.config$
|
this._configService.config$
|
||||||
.pipe(takeUntil(this._unsubscribeAll))
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
.subscribe((config: AppConfig) => {
|
.subscribe((config: AppConfig) => {
|
||||||
|
|
||||||
// Store the config
|
// Store the config
|
||||||
this.dashboardDisplay = config.dashboardDisplay;
|
this.dashboardDisplay = config.dashboard_display;
|
||||||
this.dashboardSort = config.dashboardSort;
|
this.dashboardSort = config.dashboard_sort;
|
||||||
this.temperatureUnit = config.temperatureUnit;
|
this.temperatureUnit = config.temperature_unit;
|
||||||
this.theme = config.theme;
|
this.fileSizeSIUnits = config.file_size_si_units;
|
||||||
|
this.theme = config.theme;
|
||||||
|
|
||||||
});
|
this.statusFilterAttributes = config.metrics.status_filter_attributes;
|
||||||
|
this.statusThreshold = config.metrics.status_threshold;
|
||||||
|
|
||||||
}
|
});
|
||||||
|
|
||||||
saveSettings(): void {
|
}
|
||||||
|
|
||||||
|
saveSettings(): void {
|
||||||
const newSettings = {
|
const newSettings: AppConfig = {
|
||||||
dashboardDisplay: this.dashboardDisplay,
|
dashboard_display: this.dashboardDisplay as DashboardDisplay,
|
||||||
dashboardSort: this.dashboardSort,
|
dashboard_sort: this.dashboardSort as DashboardSort,
|
||||||
temperatureUnit: this.temperatureUnit,
|
temperature_unit: this.temperatureUnit as TemperatureUnit,
|
||||||
theme: this.theme
|
file_size_si_units: this.fileSizeSIUnits,
|
||||||
|
theme: this.theme as Theme,
|
||||||
|
metrics: {
|
||||||
|
status_filter_attributes: this.statusFilterAttributes as MetricsStatusFilterAttributes,
|
||||||
|
status_threshold: this.statusThreshold as MetricsStatusThreshold
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this._configService.config = newSettings
|
this._configService.config = newSettings
|
||||||
console.log(`Saved Settings: ${JSON.stringify(newSettings)}`)
|
console.log(`Saved Settings: ${JSON.stringify(newSettings)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
formatLabel(value: number): number {
|
formatLabel(value: number): number {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import {NgModule} from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import {RouterModule} from '@angular/router';
|
||||||
import { Overlay } from '@angular/cdk/overlay';
|
import {MatAutocompleteModule} from '@angular/material/autocomplete';
|
||||||
import { MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocompleteModule } from '@angular/material/autocomplete';
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import {MatSelectModule} from '@angular/material/select';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import {MatIconModule} from '@angular/material/icon';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import {MatInputModule} from '@angular/material/input';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import {SharedModule} from 'app/shared/shared.module';
|
||||||
import { SharedModule } from 'app/shared/shared.module';
|
|
||||||
import {DetailSettingsComponent} from 'app/layout/common/detail-settings/detail-settings.component'
|
import {DetailSettingsComponent} from 'app/layout/common/detail-settings/detail-settings.component'
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import {MatDialogModule} from '@angular/material/dialog';
|
||||||
import { MatButtonToggleModule} from '@angular/material/button-toggle';
|
import {MatButtonToggleModule} from '@angular/material/button-toggle';
|
||||||
import {MatTabsModule} from '@angular/material/tabs';
|
import {MatTabsModule} from '@angular/material/tabs';
|
||||||
import {MatSliderModule} from '@angular/material/slider';
|
import {MatSliderModule} from '@angular/material/slider';
|
||||||
import {MatSlideToggleModule} from '@angular/material/slide-toggle';
|
import {MatSlideToggleModule} from '@angular/material/slide-toggle';
|
||||||
@@ -20,7 +19,7 @@ import {MatTooltipModule} from '@angular/material/tooltip';
|
|||||||
declarations: [
|
declarations: [
|
||||||
DetailSettingsComponent
|
DetailSettingsComponent
|
||||||
],
|
],
|
||||||
imports : [
|
imports: [
|
||||||
RouterModule.forChild([]),
|
RouterModule.forChild([]),
|
||||||
MatAutocompleteModule,
|
MatAutocompleteModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
@@ -36,11 +35,10 @@ import {MatTooltipModule} from '@angular/material/tooltip';
|
|||||||
MatSlideToggleModule,
|
MatSlideToggleModule,
|
||||||
SharedModule
|
SharedModule
|
||||||
],
|
],
|
||||||
exports : [
|
exports: [
|
||||||
DetailSettingsComponent
|
DetailSettingsComponent
|
||||||
],
|
],
|
||||||
providers : []
|
providers: []
|
||||||
})
|
})
|
||||||
export class DetailSettingsModule
|
export class DetailSettingsModule {
|
||||||
{
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import { Component, Inject, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
import {Component, Inject, OnDestroy, OnInit, ViewEncapsulation} from '@angular/core';
|
||||||
import { DOCUMENT } from '@angular/common';
|
import {DOCUMENT} from '@angular/common';
|
||||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
import {ActivatedRoute, NavigationEnd, Router} from '@angular/router';
|
||||||
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
import {MatSlideToggleChange} from '@angular/material/slide-toggle';
|
||||||
import { Subject } from 'rxjs';
|
import {Subject} from 'rxjs';
|
||||||
import { filter, takeUntil } from 'rxjs/operators';
|
import {filter, takeUntil} from 'rxjs/operators';
|
||||||
import { TreoConfigService } from '@treo/services/config';
|
import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service';
|
||||||
import { TreoDrawerService } from '@treo/components/drawer';
|
import {TreoDrawerService} from '@treo/components/drawer';
|
||||||
import { Layout } from 'app/layout/layout.types';
|
import {Layout} from 'app/layout/layout.types';
|
||||||
import { AppConfig, Theme } from 'app/core/config/app.config';
|
import {AppConfig, Theme} from 'app/core/config/app.config';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector : 'layout',
|
selector: 'layout',
|
||||||
templateUrl : './layout.component.html',
|
templateUrl: './layout.component.html',
|
||||||
styleUrls : ['./layout.component.scss'],
|
styleUrls: ['./layout.component.scss'],
|
||||||
encapsulation: ViewEncapsulation.None
|
encapsulation: ViewEncapsulation.None
|
||||||
})
|
})
|
||||||
export class LayoutComponent implements OnInit, OnDestroy
|
export class LayoutComponent implements OnInit, OnDestroy {
|
||||||
{
|
|
||||||
config: AppConfig;
|
config: AppConfig;
|
||||||
layout: Layout;
|
layout: Layout;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
@@ -29,14 +28,14 @@ export class LayoutComponent implements OnInit, OnDestroy
|
|||||||
* Constructor
|
* Constructor
|
||||||
*
|
*
|
||||||
* @param {ActivatedRoute} _activatedRoute
|
* @param {ActivatedRoute} _activatedRoute
|
||||||
* @param {TreoConfigService} _treoConfigService
|
* @param {ScrutinyConfigService} _scrutinyConfigService
|
||||||
* @param {TreoDrawerService} _treoDrawerService
|
* @param {TreoDrawerService} _treoDrawerService
|
||||||
* @param {DOCUMENT} _document
|
* @param {DOCUMENT} _document
|
||||||
* @param {Router} _router
|
* @param {Router} _router
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private _activatedRoute: ActivatedRoute,
|
private _activatedRoute: ActivatedRoute,
|
||||||
private _treoConfigService: TreoConfigService,
|
private _scrutinyConfigService: ScrutinyConfigService,
|
||||||
private _treoDrawerService: TreoDrawerService,
|
private _treoDrawerService: TreoDrawerService,
|
||||||
@Inject(DOCUMENT) private _document: any,
|
@Inject(DOCUMENT) private _document: any,
|
||||||
private _router: Router
|
private _router: Router
|
||||||
@@ -59,7 +58,7 @@ export class LayoutComponent implements OnInit, OnDestroy
|
|||||||
ngOnInit(): void
|
ngOnInit(): void
|
||||||
{
|
{
|
||||||
// Subscribe to config changes
|
// Subscribe to config changes
|
||||||
this._treoConfigService.config$
|
this._scrutinyConfigService.config$
|
||||||
.pipe(takeUntil(this._unsubscribeAll))
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
.subscribe((config: AppConfig) => {
|
.subscribe((config: AppConfig) => {
|
||||||
|
|
||||||
@@ -180,18 +179,17 @@ export class LayoutComponent implements OnInit, OnDestroy
|
|||||||
*
|
*
|
||||||
* @param layout
|
* @param layout
|
||||||
*/
|
*/
|
||||||
setLayout(layout: string): void
|
setLayout(layout: Layout): void {
|
||||||
{
|
|
||||||
// Clear the 'layout' query param to allow layout changes
|
// Clear the 'layout' query param to allow layout changes
|
||||||
this._router.navigate([], {
|
this._router.navigate([], {
|
||||||
queryParams : {
|
queryParams: {
|
||||||
layout: null
|
layout: null
|
||||||
},
|
},
|
||||||
queryParamsHandling: 'merge'
|
queryParamsHandling: 'merge'
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
|
|
||||||
// Set the config
|
// Set the config
|
||||||
this._treoConfigService.config = {layout};
|
this._scrutinyConfigService.config = {layout};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +200,6 @@ export class LayoutComponent implements OnInit, OnDestroy
|
|||||||
*/
|
*/
|
||||||
setTheme(change: MatSlideToggleChange): void
|
setTheme(change: MatSlideToggleChange): void
|
||||||
{
|
{
|
||||||
this._treoConfigService.config = {theme: change.checked ? 'dark' : 'light'};
|
this._scrutinyConfigService.config = {theme: change.checked ? 'dark' : 'light'};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
<div *ngIf="data && data.data && data.data.summary; else emptyDashboard">
|
<div *ngIf="summaryData; else emptyDashboard">
|
||||||
<div class="flex flex-col flex-auto w-full p-8 xs:p-2">
|
<div class="flex flex-col flex-auto w-full p-8 xs:p-2">
|
||||||
|
|
||||||
<div class="flex flex-wrap w-full">
|
<div class="flex flex-wrap w-full">
|
||||||
@@ -51,7 +51,11 @@
|
|||||||
<div class="flex flex-wrap w-full" *ngFor="let hostId of hostGroups | keyvalue">
|
<div class="flex flex-wrap w-full" *ngFor="let hostId of hostGroups | keyvalue">
|
||||||
<h3 class="ml-4" *ngIf="hostId.key">{{hostId.key}}</h3>
|
<h3 class="ml-4" *ngIf="hostId.key">{{hostId.key}}</h3>
|
||||||
<div class="flex flex-wrap w-full">
|
<div class="flex flex-wrap w-full">
|
||||||
<app-dashboard-device (deviceDeleted)="onDeviceDeleted($event)" class="flex gt-sm:w-1/2 min-w-80 p-4" *ngFor="let deviceSummary of (deviceSummariesForHostGroup(hostId.value) | deviceSort:config.dashboardSort:config.dashboardDisplay )" [deviceWWN]="deviceSummary.device.wwn" [deviceSummary]="deviceSummary"></app-dashboard-device>
|
<app-dashboard-device (deviceDeleted)="onDeviceDeleted($event)"
|
||||||
|
class="flex gt-sm:w-1/2 min-w-80 p-4"
|
||||||
|
*ngFor="let deviceSummary of (deviceSummariesForHostGroup(hostId.value) | deviceSort:config.dashboard_sort:config.dashboard_display )"
|
||||||
|
[deviceWWN]="deviceSummary.device.wwn"
|
||||||
|
[deviceSummary]="deviceSummary"></app-dashboard-device>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
|
import {
|
||||||
import { MatSort } from '@angular/material/sort';
|
AfterViewInit,
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
ChangeDetectionStrategy,
|
||||||
import { Subject } from 'rxjs';
|
Component,
|
||||||
import { takeUntil } from 'rxjs/operators';
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
ViewChild,
|
||||||
|
ViewEncapsulation
|
||||||
|
} from '@angular/core';
|
||||||
|
import {Subject} from 'rxjs';
|
||||||
|
import {takeUntil} from 'rxjs/operators';
|
||||||
import {ApexOptions, ChartComponent} from 'ng-apexcharts';
|
import {ApexOptions, ChartComponent} from 'ng-apexcharts';
|
||||||
import { DashboardService } from 'app/modules/dashboard/dashboard.service';
|
import {DashboardService} from 'app/modules/dashboard/dashboard.service';
|
||||||
import {MatDialog} from '@angular/material/dialog';
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
import { DashboardSettingsComponent } from 'app/layout/common/dashboard-settings/dashboard-settings.component';
|
import {DashboardSettingsComponent} from 'app/layout/common/dashboard-settings/dashboard-settings.component';
|
||||||
import {AppConfig} from 'app/core/config/app.config';
|
import {AppConfig} from 'app/core/config/app.config';
|
||||||
import {TreoConfigService} from '@treo/services/config';
|
import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service';
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {TemperaturePipe} from 'app/shared/temperature.pipe';
|
import {TemperaturePipe} from 'app/shared/temperature.pipe';
|
||||||
import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
|
import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
|
||||||
|
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector : 'example',
|
selector : 'example',
|
||||||
@@ -22,7 +29,7 @@ import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
|
|||||||
})
|
})
|
||||||
export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
|
export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
|
||||||
{
|
{
|
||||||
data: any;
|
summaryData: { [key: string]: DeviceSummaryModel };
|
||||||
hostGroups: { [hostId: string]: string[] } = {}
|
hostGroups: { [hostId: string]: string[] } = {}
|
||||||
temperatureOptions: ApexOptions;
|
temperatureOptions: ApexOptions;
|
||||||
tempDurationKey = 'forever'
|
tempDurationKey = 'forever'
|
||||||
@@ -35,11 +42,14 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
*
|
*
|
||||||
* @param {SmartService} _smartService
|
* @param {DashboardService} _dashboardService
|
||||||
|
* @param {ScrutinyConfigService} _configService
|
||||||
|
* @param {MatDialog} dialog
|
||||||
|
* @param {Router} router
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private _smartService: DashboardService,
|
private _dashboardService: DashboardService,
|
||||||
private _configService: TreoConfigService,
|
private _configService: ScrutinyConfigService,
|
||||||
public dialog: MatDialog,
|
public dialog: MatDialog,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
)
|
)
|
||||||
@@ -81,16 +91,16 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get the data
|
// Get the data
|
||||||
this._smartService.data$
|
this._dashboardService.data$
|
||||||
.pipe(takeUntil(this._unsubscribeAll))
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
.subscribe((data) => {
|
.subscribe((data) => {
|
||||||
|
|
||||||
// Store the data
|
// Store the data
|
||||||
this.data = data;
|
this.summaryData = data;
|
||||||
|
|
||||||
// generate group data.
|
// generate group data.
|
||||||
for(const wwn in this.data.data.summary){
|
for (const wwn in this.summaryData) {
|
||||||
const hostid = this.data.data.summary[wwn].device.host_id
|
const hostid = this.summaryData[wwn].device.host_id
|
||||||
const hostDeviceList = this.hostGroups[hostid] || []
|
const hostDeviceList = this.hostGroups[hostid] || []
|
||||||
hostDeviceList.push(wwn)
|
hostDeviceList.push(wwn)
|
||||||
this.hostGroups[hostid] = hostDeviceList
|
this.hostGroups[hostid] = hostDeviceList
|
||||||
@@ -132,15 +142,15 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
private _deviceDataTemperatureSeries(): any[] {
|
private _deviceDataTemperatureSeries(): any[] {
|
||||||
const deviceTemperatureSeries = []
|
const deviceTemperatureSeries = []
|
||||||
|
|
||||||
console.log('DEVICE DATA SUMMARY', this.data)
|
console.log('DEVICE DATA SUMMARY', this.summaryData)
|
||||||
|
|
||||||
for(const wwn in this.data.data.summary){
|
for (const wwn in this.summaryData) {
|
||||||
const deviceSummary = this.data.data.summary[wwn]
|
const deviceSummary = this.summaryData[wwn]
|
||||||
if (!deviceSummary.temp_history){
|
if (!deviceSummary.temp_history) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceName = DeviceTitlePipe.deviceTitleWithFallback(deviceSummary.device, this.config.dashboardDisplay)
|
const deviceName = DeviceTitlePipe.deviceTitleWithFallback(deviceSummary.device, this.config.dashboard_display)
|
||||||
|
|
||||||
const deviceSeriesMetadata = {
|
const deviceSeriesMetadata = {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
@@ -151,7 +161,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
const newDate = new Date(tempHistory.date);
|
const newDate = new Date(tempHistory.date);
|
||||||
deviceSeriesMetadata.data.push({
|
deviceSeriesMetadata.data.push({
|
||||||
x: newDate,
|
x: newDate,
|
||||||
y: TemperaturePipe.formatTemperature(tempHistory.temp, this.config.temperatureUnit, false)
|
y: TemperaturePipe.formatTemperature(tempHistory.temp, this.config.temperature_unit, false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
deviceTemperatureSeries.push(deviceSeriesMetadata)
|
deviceTemperatureSeries.push(deviceSeriesMetadata)
|
||||||
@@ -202,11 +212,11 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
y : {
|
y : {
|
||||||
|
|
||||||
formatter: (value) => {
|
formatter: (value) => {
|
||||||
return TemperaturePipe.formatTemperature(value, this.config.temperatureUnit, true) as string;
|
return TemperaturePipe.formatTemperature(value, this.config.temperature_unit, true) as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
xaxis : {
|
xaxis: {
|
||||||
type: 'datetime'
|
type: 'datetime'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -216,18 +226,18 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
// @ Public methods
|
// @ Public methods
|
||||||
// -----------------------------------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
deviceSummariesForHostGroup(hostGroupWWNs: string[]): any[] {
|
deviceSummariesForHostGroup(hostGroupWWNs: string[]): DeviceSummaryModel[] {
|
||||||
const deviceSummaries = []
|
const deviceSummaries: DeviceSummaryModel[] = []
|
||||||
for(const wwn of hostGroupWWNs){
|
for (const wwn of hostGroupWWNs) {
|
||||||
if(this.data.data.summary[wwn]){
|
if (this.summaryData[wwn]) {
|
||||||
deviceSummaries.push(this.data.data.summary[wwn])
|
deviceSummaries.push(this.summaryData[wwn])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return deviceSummaries
|
return deviceSummaries
|
||||||
}
|
}
|
||||||
|
|
||||||
openDialog(): void {
|
openDialog(): void {
|
||||||
const dialogRef = this.dialog.open(DashboardSettingsComponent);
|
const dialogRef = this.dialog.open(DashboardSettingsComponent, {width: '600px',});
|
||||||
|
|
||||||
dialogRef.afterClosed().subscribe(result => {
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
console.log(`Dialog result: ${result}`);
|
console.log(`Dialog result: ${result}`);
|
||||||
@@ -235,7 +245,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDeviceDeleted(wwn: string): void {
|
onDeviceDeleted(wwn: string): void {
|
||||||
delete this.data.data.summary[wwn] // remove the device from the summary list.
|
delete this.summaryData[wwn] // remove the device from the summary list.
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -246,16 +256,16 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
DURATION_KEY_FOREVER = "forever"
|
DURATION_KEY_FOREVER = "forever"
|
||||||
*/
|
*/
|
||||||
|
|
||||||
changeSummaryTempDuration(durationKey: string){
|
changeSummaryTempDuration(durationKey: string): void {
|
||||||
this.tempDurationKey = durationKey
|
this.tempDurationKey = durationKey
|
||||||
|
|
||||||
this._smartService.getSummaryTempData(durationKey)
|
this._dashboardService.getSummaryTempData(durationKey)
|
||||||
.subscribe((data) => {
|
.subscribe((tempHistoryData) => {
|
||||||
|
|
||||||
// given a list of device temp history, override the data in the "summary" object.
|
// given a list of device temp history, override the data in the "summary" object.
|
||||||
for(const wwn in this.data.data.summary) {
|
for (const wwn in this.summaryData) {
|
||||||
// console.log(`Updating ${wwn}, length: ${this.data.data.summary[wwn].temp_history.length}`)
|
// console.log(`Updating ${wwn}, length: ${this.data.data.summary[wwn].temp_history.length}`)
|
||||||
this.data.data.summary[wwn].temp_history = data.data.temp_history[wwn] || []
|
this.summaryData[wwn].temp_history = tempHistoryData[wwn] || []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the chart series data
|
// Prepare the chart series data
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import {Injectable} from '@angular/core';
|
||||||
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router';
|
||||||
import { Observable } from 'rxjs';
|
import {Observable} from 'rxjs';
|
||||||
import { DashboardService } from 'app/modules/dashboard/dashboard.service';
|
import {DashboardService} from 'app/modules/dashboard/dashboard.service';
|
||||||
|
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class DashboardResolver implements Resolve<any>
|
export class DashboardResolver implements Resolve<any> {
|
||||||
{
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
*
|
*
|
||||||
@@ -29,8 +29,7 @@ export class DashboardResolver implements Resolve<any>
|
|||||||
* @param route
|
* @param route
|
||||||
* @param state
|
* @param state
|
||||||
*/
|
*/
|
||||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any>
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<{ [p: string]: DeviceSummaryModel }> {
|
||||||
{
|
|
||||||
return this._dashboardService.getSummaryData();
|
return this._dashboardService.getSummaryData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {HttpClient} from '@angular/common/http';
|
||||||
|
import {DashboardService} from './dashboard.service';
|
||||||
|
import {of} from 'rxjs';
|
||||||
|
import {summary} from 'app/data/mock/summary/data'
|
||||||
|
import {temp_history} from 'app/data/mock/summary/temp_history'
|
||||||
|
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
|
||||||
|
import {SmartTemperatureModel} from 'app/core/models/measurements/smart-temperature-model';
|
||||||
|
|
||||||
|
describe('DashboardService', () => {
|
||||||
|
let service: DashboardService;
|
||||||
|
let httpClientSpy: jasmine.SpyObj<HttpClient>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
|
||||||
|
service = new DashboardService(httpClientSpy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unwrap and return getSummaryData() (HttpClient called once)', (done: DoneFn) => {
|
||||||
|
httpClientSpy.get.and.returnValue(of(summary));
|
||||||
|
|
||||||
|
service.getSummaryData().subscribe(value => {
|
||||||
|
expect(value).toBe(summary.data.summary as { [key: string]: DeviceSummaryModel });
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
expect(httpClientSpy.get.calls.count())
|
||||||
|
.withContext('one call')
|
||||||
|
.toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unwrap and return getSummaryTempData() (HttpClient called once)', (done: DoneFn) => {
|
||||||
|
// const expectedHeroes: any[] =
|
||||||
|
// [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
|
||||||
|
|
||||||
|
httpClientSpy.get.and.returnValue(of(temp_history));
|
||||||
|
|
||||||
|
service.getSummaryTempData('weekly').subscribe(value => {
|
||||||
|
expect(value).toBe(temp_history.data.temp_history as { [key: string]: SmartTemperatureModel[] });
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
expect(httpClientSpy.get.calls.count())
|
||||||
|
.withContext('one call')
|
||||||
|
.toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import {Injectable} from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import {HttpClient} from '@angular/common/http';
|
||||||
import { BehaviorSubject, Observable } from 'rxjs';
|
import {BehaviorSubject, Observable} from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
import {map, tap} from 'rxjs/operators';
|
||||||
import { getBasePath } from 'app/app.routing';
|
import {getBasePath} from 'app/app.routing';
|
||||||
|
import {DeviceSummaryResponseWrapper} from 'app/core/models/device-summary-response-wrapper';
|
||||||
|
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
|
||||||
|
import {SmartTemperatureModel} from 'app/core/models/measurements/smart-temperature-model';
|
||||||
|
import {DeviceSummaryTempResponseWrapper} from 'app/core/models/device-summary-temp-response-wrapper';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class DashboardService
|
export class DashboardService {
|
||||||
{
|
|
||||||
// Observables
|
// Observables
|
||||||
private _data: BehaviorSubject<any>;
|
private _data: BehaviorSubject<{ [p: string]: DeviceSummaryModel }>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
@@ -32,8 +35,7 @@ export class DashboardService
|
|||||||
/**
|
/**
|
||||||
* Getter for data
|
* Getter for data
|
||||||
*/
|
*/
|
||||||
get data$(): Observable<any>
|
get data$(): Observable<{ [p: string]: DeviceSummaryModel }> {
|
||||||
{
|
|
||||||
return this._data.asObservable();
|
return this._data.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,22 +46,28 @@ export class DashboardService
|
|||||||
/**
|
/**
|
||||||
* Get data
|
* Get data
|
||||||
*/
|
*/
|
||||||
getSummaryData(): Observable<any>
|
getSummaryData(): Observable<{ [key: string]: DeviceSummaryModel }> {
|
||||||
{
|
|
||||||
return this._httpClient.get(getBasePath() + '/api/summary').pipe(
|
return this._httpClient.get(getBasePath() + '/api/summary').pipe(
|
||||||
tap((response: any) => {
|
map((response: DeviceSummaryResponseWrapper) => {
|
||||||
|
// console.log("FILTERING=----", response.data.summary)
|
||||||
|
return response.data.summary
|
||||||
|
}),
|
||||||
|
tap((response: { [key: string]: DeviceSummaryModel }) => {
|
||||||
this._data.next(response);
|
this._data.next(response);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSummaryTempData(durationKey: string): Observable<any>
|
getSummaryTempData(durationKey: string): Observable<{ [key: string]: SmartTemperatureModel[] }> {
|
||||||
{
|
|
||||||
const params = {}
|
const params = {}
|
||||||
if(durationKey){
|
if (durationKey) {
|
||||||
params['duration_key'] = durationKey
|
params['duration_key'] = durationKey
|
||||||
}
|
}
|
||||||
|
|
||||||
return this._httpClient.get(getBasePath() + '/api/summary/temp', {params: params});
|
return this._httpClient.get(getBasePath() + '/api/summary/temp', {params: params}).pipe(
|
||||||
|
map((response: DeviceSummaryTempResponseWrapper) => {
|
||||||
|
return response.data.temp_history
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
<div class="flex items-center justify-between w-full my-4 px-4 xs:pr-0">
|
<div class="flex items-center justify-between w-full my-4 px-4 xs:pr-0">
|
||||||
<div class="mr-6">
|
<div class="mr-6">
|
||||||
<h2 class="m-0">Drive Details - {{device | deviceTitle:config.dashboardDisplay}} </h2>
|
<h2 class="m-0">Drive Details - {{device | deviceTitle:config.dashboard_display}} </h2>
|
||||||
<div class="text-secondary tracking-tight">Dive into S.M.A.R.T data</div>
|
<div class="text-secondary tracking-tight">Dive into S.M.A.R.T data</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<span class="ml-2">Export</span>
|
<span class="ml-2">Export</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="ml-2 xs:hidden"
|
<button class="ml-2 xs:hidden"
|
||||||
(click)="openDialog()"
|
matTooltip="not yet implemented"
|
||||||
mat-stroked-button>
|
mat-stroked-button>
|
||||||
<mat-icon class="icon-size-20 rotate-90 mirror"
|
<mat-icon class="icon-size-20 rotate-90 mirror"
|
||||||
[svgIcon]="'tune'"></mat-icon>
|
[svgIcon]="'tune'"></mat-icon>
|
||||||
@@ -56,12 +56,13 @@
|
|||||||
<div *ngIf="device" class="my-2 col-span-2 lt-md:col-span-1">
|
<div *ngIf="device" class="my-2 col-span-2 lt-md:col-span-1">
|
||||||
<div>
|
<div>
|
||||||
<span class="inline-flex items-center font-bold text-xs px-2 py-2px rounded-full tracking-wide uppercase"
|
<span class="inline-flex items-center font-bold text-xs px-2 py-2px rounded-full tracking-wide uppercase"
|
||||||
[ngClass]="{'red-200': device?.device_status != 0,
|
[ngClass]="{'red-200': deviceStatusForModelWithThreshold(device, !!smart_results, config.metrics.status_threshold) == 'failed',
|
||||||
'green-200': device?.device_status == 0}">
|
'green-200': device?.device_status == 0}">
|
||||||
<span class="w-2 h-2 rounded-full mr-2"
|
<span class="w-2 h-2 rounded-full mr-2"
|
||||||
[ngClass]="{'bg-red': device?.device_status != 0,
|
[ngClass]="{'bg-red': device?.device_status != 0,
|
||||||
'bg-green': device?.device_status == 0}"></span>
|
'bg-green': device?.device_status == 0}"></span>
|
||||||
<span class="pr-2px leading-relaxed whitespace-no-wrap">{{device?.device_status | deviceStatus}}</span>
|
<span
|
||||||
|
class="pr-2px leading-relaxed whitespace-no-wrap">{{device | deviceStatus:!!smart_results:config.metrics.status_threshold:true}}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-secondary text-md">Status</div>
|
<div class="text-secondary text-md">Status</div>
|
||||||
@@ -106,7 +107,7 @@
|
|||||||
<div class="text-secondary text-md">Firmware Version</div>
|
<div class="text-secondary text-md">Firmware Version</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-2 col-span-2 lt-md:col-span-1">
|
<div class="my-2 col-span-2 lt-md:col-span-1">
|
||||||
<div>{{device?.capacity | fileSize}}</div>
|
<div>{{device?.capacity | fileSize:config.file_size_si_units}}</div>
|
||||||
<div class="text-secondary text-md">Capacity</div>
|
<div class="text-secondary text-md">Capacity</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="device?.rotational_speed" class="my-2 col-span-2 lt-md:col-span-1">
|
<div *ngIf="device?.rotational_speed" class="my-2 col-span-2 lt-md:col-span-1">
|
||||||
@@ -126,7 +127,7 @@
|
|||||||
<div class="text-secondary text-md">Powered On</div>
|
<div class="text-secondary text-md">Powered On</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-2 col-span-2 lt-md:col-span-1">
|
<div class="my-2 col-span-2 lt-md:col-span-1">
|
||||||
<div>{{smart_results[0]?.temp | temperature:config.temperatureUnit:true}}</div>
|
<div>{{smart_results[0]?.temp | temperature:config.temperature_unit:true}}</div>
|
||||||
<div class="text-secondary text-md">Temperature</div>
|
<div class="text-secondary text-md">Temperature</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { DetailComponent } from './detail.component';
|
|
||||||
|
|
||||||
describe('DetailComponent', () => {
|
|
||||||
let component: DetailComponent;
|
|
||||||
let fixture: ComponentFixture<DetailComponent>;
|
|
||||||
|
|
||||||
beforeEach(async(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [ DetailComponent ]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(DetailComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
import {AfterViewInit, Component, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
import humanizeDuration from 'humanize-duration';
|
||||||
|
import {AfterViewInit, Component, Inject, LOCALE_ID, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||||
import {ApexOptions} from 'ng-apexcharts';
|
import {ApexOptions} from 'ng-apexcharts';
|
||||||
import {MatTableDataSource} from '@angular/material/table';
|
import {AppConfig} from 'app/core/config/app.config';
|
||||||
import {MatSort} from '@angular/material/sort';
|
|
||||||
import {Subject} from 'rxjs';
|
|
||||||
import {DetailService} from './detail.service';
|
import {DetailService} from './detail.service';
|
||||||
import {takeUntil} from 'rxjs/operators';
|
|
||||||
import {DetailSettingsComponent} from 'app/layout/common/detail-settings/detail-settings.component';
|
import {DetailSettingsComponent} from 'app/layout/common/detail-settings/detail-settings.component';
|
||||||
import {MatDialog} from '@angular/material/dialog';
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
import humanizeDuration from 'humanize-duration';
|
import {MatSort} from '@angular/material/sort';
|
||||||
import {TreoConfigService} from '@treo/services/config';
|
import {MatTableDataSource} from '@angular/material/table';
|
||||||
import {AppConfig} from 'app/core/config/app.config';
|
import {Subject} from 'rxjs';
|
||||||
|
import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service';
|
||||||
import {animate, state, style, transition, trigger} from '@angular/animations';
|
import {animate, state, style, transition, trigger} from '@angular/animations';
|
||||||
import {formatDate} from '@angular/common';
|
import {formatDate} from '@angular/common';
|
||||||
import { LOCALE_ID, Inject } from '@angular/core';
|
import {takeUntil} from 'rxjs/operators';
|
||||||
|
import {DeviceModel} from 'app/core/models/device-model';
|
||||||
|
import {SmartModel} from 'app/core/models/measurements/smart-model';
|
||||||
|
import {SmartAttributeModel} from 'app/core/models/measurements/smart-attribute-model';
|
||||||
|
import {AttributeMetadataModel} from 'app/core/models/thresholds/attribute-metadata-model';
|
||||||
|
import {DeviceStatusPipe} from 'app/shared/device-status.pipe';
|
||||||
|
|
||||||
// from Constants.go - these must match
|
// from Constants.go - these must match
|
||||||
const AttributeStatusPassed = 0
|
const AttributeStatusPassed = 0
|
||||||
@@ -22,9 +26,9 @@ const AttributeStatusFailedScrutiny = 4
|
|||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'detail',
|
selector: 'detail',
|
||||||
templateUrl: './detail.component.html',
|
templateUrl: './detail.component.html',
|
||||||
styleUrls: ['./detail.component.scss'],
|
styleUrls: ['./detail.component.scss'],
|
||||||
animations: [
|
animations: [
|
||||||
trigger('detailExpand', [
|
trigger('detailExpand', [
|
||||||
state('collapsed', style({height: '0px', minHeight: '0'})),
|
state('collapsed', style({height: '0px', minHeight: '0'})),
|
||||||
@@ -40,22 +44,23 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* Constructor
|
* Constructor
|
||||||
*
|
*
|
||||||
* @param {DetailService} _detailService
|
* @param {DetailService} _detailService
|
||||||
|
* @param {MatDialog} dialog
|
||||||
|
* @param {ScrutinyConfigService} _configService
|
||||||
|
* @param {string} locale
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private _detailService: DetailService,
|
private _detailService: DetailService,
|
||||||
public dialog: MatDialog,
|
public dialog: MatDialog,
|
||||||
private _configService: TreoConfigService,
|
private _configService: ScrutinyConfigService,
|
||||||
@Inject(LOCALE_ID) public locale: string
|
@Inject(LOCALE_ID) public locale: string
|
||||||
|
) {
|
||||||
)
|
|
||||||
{
|
|
||||||
// Set the private defaults
|
// Set the private defaults
|
||||||
this._unsubscribeAll = new Subject();
|
this._unsubscribeAll = new Subject();
|
||||||
|
|
||||||
// Set the defaults
|
// Set the defaults
|
||||||
this.smartAttributeDataSource = new MatTableDataSource();
|
this.smartAttributeDataSource = new MatTableDataSource();
|
||||||
// this.recentTransactionsTableColumns = ['status', 'id', 'name', 'value', 'worst', 'thresh'];
|
// this.recentTransactionsTableColumns = ['status', 'id', 'name', 'value', 'worst', 'thresh'];
|
||||||
this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'worst', 'thresh','ideal', 'failure', 'history'];
|
this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'worst', 'thresh', 'ideal', 'failure', 'history'];
|
||||||
|
|
||||||
this.systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
this.systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
@@ -65,14 +70,15 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
onlyCritical = true;
|
onlyCritical = true;
|
||||||
// data: any;
|
// data: any;
|
||||||
expandedAttribute: any | null;
|
expandedAttribute: SmartAttributeModel | null;
|
||||||
|
|
||||||
metadata: any;
|
metadata: { [p: string]: AttributeMetadataModel } | { [p: number]: AttributeMetadataModel };
|
||||||
device: any;
|
device: DeviceModel;
|
||||||
smart_results: any[];
|
// tslint:disable-next-line:variable-name
|
||||||
|
smart_results: SmartModel[];
|
||||||
|
|
||||||
commonSparklineOptions: Partial<ApexOptions>;
|
commonSparklineOptions: Partial<ApexOptions>;
|
||||||
smartAttributeDataSource: MatTableDataSource<any>;
|
smartAttributeDataSource: MatTableDataSource<SmartAttributeModel>;
|
||||||
smartAttributeTableColumns: string[];
|
smartAttributeTableColumns: string[];
|
||||||
|
|
||||||
@ViewChild('smartAttributeTable', {read: MatSort})
|
@ViewChild('smartAttributeTable', {read: MatSort})
|
||||||
@@ -84,6 +90,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
readonly humanizeDuration = humanizeDuration;
|
readonly humanizeDuration = humanizeDuration;
|
||||||
|
|
||||||
|
deviceStatusForModelWithThreshold = DeviceStatusPipe.deviceStatusForModelWithThreshold
|
||||||
// -----------------------------------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------------------------------
|
||||||
// @ Lifecycle hooks
|
// @ Lifecycle hooks
|
||||||
// -----------------------------------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------------------------------
|
||||||
@@ -91,8 +98,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* On init
|
* On init
|
||||||
*/
|
*/
|
||||||
ngOnInit(): void
|
ngOnInit(): void {
|
||||||
{
|
|
||||||
// Subscribe to config changes
|
// Subscribe to config changes
|
||||||
this._configService.config$
|
this._configService.config$
|
||||||
.pipe(takeUntil(this._unsubscribeAll))
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
@@ -104,13 +110,13 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
// Get the data
|
// Get the data
|
||||||
this._detailService.data$
|
this._detailService.data$
|
||||||
.pipe(takeUntil(this._unsubscribeAll))
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
.subscribe((data) => {
|
.subscribe((respWrapper) => {
|
||||||
|
|
||||||
// Store the data
|
// Store the data
|
||||||
// this.data = data;
|
// this.data = data;
|
||||||
this.device = data.data.device;
|
this.device = respWrapper.data.device;
|
||||||
this.smart_results = data.data.smart_results
|
this.smart_results = respWrapper.data.smart_results
|
||||||
this.metadata = data.metadata;
|
this.metadata = respWrapper.metadata;
|
||||||
|
|
||||||
|
|
||||||
// Store the table data
|
// Store the table data
|
||||||
@@ -124,8 +130,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* After view init
|
* After view init
|
||||||
*/
|
*/
|
||||||
ngAfterViewInit(): void
|
ngAfterViewInit(): void {
|
||||||
{
|
|
||||||
// Make the data source sortable
|
// Make the data source sortable
|
||||||
this.smartAttributeDataSource.sort = this.smartAttributeTableMatSort;
|
this.smartAttributeDataSource.sort = this.smartAttributeTableMatSort;
|
||||||
}
|
}
|
||||||
@@ -133,8 +138,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* On destroy
|
* On destroy
|
||||||
*/
|
*/
|
||||||
ngOnDestroy(): void
|
ngOnDestroy(): void {
|
||||||
{
|
|
||||||
// Unsubscribe from all subscriptions
|
// Unsubscribe from all subscriptions
|
||||||
this._unsubscribeAll.next();
|
this._unsubscribeAll.next();
|
||||||
this._unsubscribeAll.complete();
|
this._unsubscribeAll.complete();
|
||||||
@@ -147,22 +151,23 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
getAttributeStatusName(attributeStatus: number): string {
|
getAttributeStatusName(attributeStatus: number): string {
|
||||||
// tslint:disable:no-bitwise
|
// tslint:disable:no-bitwise
|
||||||
|
|
||||||
if(attributeStatus === AttributeStatusPassed){
|
if (attributeStatus === AttributeStatusPassed) {
|
||||||
return 'passed'
|
return 'passed'
|
||||||
|
|
||||||
} else if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0 || (attributeStatus & AttributeStatusFailedSmart) !== 0 ){
|
} else if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0 || (attributeStatus & AttributeStatusFailedSmart) !== 0) {
|
||||||
return 'failed'
|
return 'failed'
|
||||||
} else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0){
|
} else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0) {
|
||||||
return 'warn'
|
return 'warn'
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
// tslint:enable:no-bitwise
|
// tslint:enable:no-bitwise
|
||||||
}
|
}
|
||||||
|
|
||||||
getAttributeScrutinyStatusName(attributeStatus: number): string {
|
getAttributeScrutinyStatusName(attributeStatus: number): string {
|
||||||
// tslint:disable:no-bitwise
|
// tslint:disable:no-bitwise
|
||||||
if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0){
|
if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0) {
|
||||||
return 'failed'
|
return 'failed'
|
||||||
} else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0){
|
} else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0) {
|
||||||
return 'warn'
|
return 'warn'
|
||||||
} else {
|
} else {
|
||||||
return 'passed'
|
return 'passed'
|
||||||
@@ -172,7 +177,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
getAttributeSmartStatusName(attributeStatus: number): string {
|
getAttributeSmartStatusName(attributeStatus: number): string {
|
||||||
// tslint:disable:no-bitwise
|
// tslint:disable:no-bitwise
|
||||||
if ((attributeStatus & AttributeStatusFailedSmart) !== 0){
|
if ((attributeStatus & AttributeStatusFailedSmart) !== 0) {
|
||||||
return 'failed'
|
return 'failed'
|
||||||
} else {
|
} else {
|
||||||
return 'passed'
|
return 'passed'
|
||||||
@@ -181,138 +186,140 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getAttributeName(attribute_data): string {
|
getAttributeName(attributeData: SmartAttributeModel): string {
|
||||||
const attribute_metadata = this.metadata[attribute_data.attribute_id]
|
const attributeMetadata = this.metadata[attributeData.attribute_id]
|
||||||
if(!attribute_metadata){
|
if (!attributeMetadata) {
|
||||||
return 'Unknown Attribute Name'
|
return 'Unknown Attribute Name'
|
||||||
} else {
|
} else {
|
||||||
return attribute_metadata.display_name
|
return attributeMetadata.display_name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getAttributeDescription(attribute_data){
|
|
||||||
const attribute_metadata = this.metadata[attribute_data.attribute_id]
|
getAttributeDescription(attributeData: SmartAttributeModel): string {
|
||||||
if(!attribute_metadata){
|
const attributeMetadata = this.metadata[attributeData.attribute_id]
|
||||||
|
if (!attributeMetadata) {
|
||||||
return 'Unknown'
|
return 'Unknown'
|
||||||
} else {
|
} else {
|
||||||
return attribute_metadata.description
|
return attributeMetadata.description
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAttributeValue(attribute_data){
|
getAttributeValue(attributeData: SmartAttributeModel): number {
|
||||||
if(this.isAta()) {
|
if (this.isAta()) {
|
||||||
const attribute_metadata = this.metadata[attribute_data.attribute_id]
|
const attributeMetadata = this.metadata[attributeData.attribute_id]
|
||||||
if(!attribute_metadata){
|
if (!attributeMetadata) {
|
||||||
return attribute_data.value
|
return attributeData.value
|
||||||
} else if (attribute_metadata.display_type == 'raw') {
|
} else if (attributeMetadata.display_type === 'raw') {
|
||||||
return attribute_data.raw_value
|
return attributeData.raw_value
|
||||||
} else if (attribute_metadata.display_type == 'transformed' && attribute_data.transformed_value) {
|
} else if (attributeMetadata.display_type === 'transformed' && attributeData.transformed_value) {
|
||||||
return attribute_data.transformed_value
|
return attributeData.transformed_value
|
||||||
} else {
|
} else {
|
||||||
return attribute_data.value
|
return attributeData.value
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else{
|
return attributeData.value
|
||||||
return attribute_data.value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAttributeValueType(attribute_data){
|
getAttributeValueType(attributeData: SmartAttributeModel): string {
|
||||||
if(this.isAta()) {
|
if (this.isAta()) {
|
||||||
const attribute_metadata = this.metadata[attribute_data.attribute_id]
|
const attributeMetadata = this.metadata[attributeData.attribute_id]
|
||||||
if(!attribute_metadata){
|
if (!attributeMetadata) {
|
||||||
return ''
|
return ''
|
||||||
} else {
|
} else {
|
||||||
return attribute_metadata.display_type
|
return attributeMetadata.display_type
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAttributeIdeal(attribute_data){
|
getAttributeIdeal(attributeData: SmartAttributeModel): string {
|
||||||
if(this.isAta()){
|
if (this.isAta()) {
|
||||||
return this.metadata[attribute_data.attribute_id]?.display_type == 'raw' ? this.metadata[attribute_data.attribute_id]?.ideal : ''
|
return this.metadata[attributeData.attribute_id]?.display_type === 'raw' ? this.metadata[attributeData.attribute_id]?.ideal : ''
|
||||||
} else {
|
} else {
|
||||||
return this.metadata[attribute_data.attribute_id]?.ideal
|
return this.metadata[attributeData.attribute_id]?.ideal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAttributeWorst(attribute_data){
|
getAttributeWorst(attributeData: SmartAttributeModel): number | string {
|
||||||
const attribute_metadata = this.metadata[attribute_data.attribute_id]
|
const attributeMetadata = this.metadata[attributeData.attribute_id]
|
||||||
if(!attribute_metadata){
|
if (!attributeMetadata) {
|
||||||
return attribute_data.worst
|
return attributeData.worst
|
||||||
} else {
|
} else {
|
||||||
return attribute_metadata?.display_type == 'normalized' ? attribute_data.worst : ''
|
return attributeMetadata?.display_type === 'normalized' ? attributeData.worst : ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAttributeThreshold(attribute_data){
|
getAttributeThreshold(attributeData: SmartAttributeModel): number | string {
|
||||||
if(this.isAta()){
|
if (this.isAta()) {
|
||||||
const attribute_metadata = this.metadata[attribute_data.attribute_id]
|
const attributeMetadata = this.metadata[attributeData.attribute_id]
|
||||||
if(!attribute_metadata || attribute_metadata.display_type == 'normalized'){
|
if (!attributeMetadata || attributeMetadata.display_type === 'normalized') {
|
||||||
return attribute_data.thresh
|
return attributeData.thresh
|
||||||
} else {
|
} else {
|
||||||
// if(this.data.metadata[attribute_data.attribute_id].observed_thresholds){
|
// if(this.data.metadata[attribute_data.attribute_id].observed_thresholds){
|
||||||
//
|
//
|
||||||
// } else {
|
// } else {
|
||||||
// }
|
// }
|
||||||
// return ''
|
// return ''
|
||||||
return attribute_data.thresh
|
return attributeData.thresh
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return (attribute_data.thresh == -1 ? '' : attribute_data.thresh )
|
return (attributeData.thresh === -1 ? '' : attributeData.thresh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAttributeCritical(attribute_data){
|
getAttributeCritical(attributeData: SmartAttributeModel): boolean {
|
||||||
return this.metadata[attribute_data.attribute_id]?.critical
|
return this.metadata[attributeData.attribute_id]?.critical
|
||||||
}
|
}
|
||||||
getHiddenAttributes(){
|
|
||||||
if (!this.smart_results || this.smart_results.length == 0) {
|
getHiddenAttributes(): number {
|
||||||
|
if (!this.smart_results || this.smart_results.length === 0) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
let attributes_length = 0
|
let attributesLength = 0
|
||||||
const attributes = this.smart_results[0]?.attrs
|
const attributes = this.smart_results[0]?.attrs
|
||||||
if (attributes) {
|
if (attributes) {
|
||||||
attributes_length = Object.keys(attributes).length
|
attributesLength = Object.keys(attributes).length
|
||||||
}
|
}
|
||||||
|
|
||||||
return attributes_length - this.smartAttributeDataSource.data.length
|
return attributesLength - this.smartAttributeDataSource.data.length
|
||||||
}
|
}
|
||||||
|
|
||||||
isAta(): boolean {
|
isAta(): boolean {
|
||||||
return this.device.device_protocol == 'ATA'
|
return this.device.device_protocol === 'ATA'
|
||||||
}
|
}
|
||||||
|
|
||||||
isScsi(): boolean {
|
isScsi(): boolean {
|
||||||
return this.device.device_protocol == 'SCSI'
|
return this.device.device_protocol === 'SCSI'
|
||||||
}
|
}
|
||||||
|
|
||||||
isNvme(): boolean {
|
isNvme(): boolean {
|
||||||
return this.device.device_protocol == 'NVMe'
|
return this.device.device_protocol === 'NVMe'
|
||||||
}
|
}
|
||||||
|
|
||||||
private _generateSmartAttributeTableDataSource(smart_results){
|
private _generateSmartAttributeTableDataSource(smartResults: SmartModel[]): SmartAttributeModel[] {
|
||||||
const smartAttributeDataSource = [];
|
const smartAttributeDataSource: SmartAttributeModel[] = [];
|
||||||
|
|
||||||
if(smart_results.length == 0){
|
if (smartResults.length === 0) {
|
||||||
return smartAttributeDataSource
|
return smartAttributeDataSource
|
||||||
}
|
}
|
||||||
const latest_smart_result = smart_results[0];
|
const latestSmartResult = smartResults[0];
|
||||||
let attributes = {}
|
let attributes: { [p: string]: SmartAttributeModel } = {}
|
||||||
if(this.isScsi()) {
|
if (this.isScsi()) {
|
||||||
this.smartAttributeTableColumns = ['status', 'name', 'value', 'thresh', 'history'];
|
this.smartAttributeTableColumns = ['status', 'name', 'value', 'thresh', 'history'];
|
||||||
attributes = latest_smart_result.attrs
|
attributes = latestSmartResult.attrs
|
||||||
} else if(this.isNvme()){
|
} else if (this.isNvme()) {
|
||||||
this.smartAttributeTableColumns = ['status', 'name', 'value', 'thresh', 'ideal', 'history'];
|
this.smartAttributeTableColumns = ['status', 'name', 'value', 'thresh', 'ideal', 'history'];
|
||||||
attributes = latest_smart_result.attrs
|
attributes = latestSmartResult.attrs
|
||||||
} else {
|
} else {
|
||||||
// ATA
|
// ATA
|
||||||
attributes = latest_smart_result.attrs
|
attributes = latestSmartResult.attrs
|
||||||
this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'thresh','ideal', 'failure', 'history'];
|
this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'thresh', 'ideal', 'failure', 'history'];
|
||||||
}
|
}
|
||||||
|
|
||||||
for(const attrId in attributes){
|
for (const attrId in attributes) {
|
||||||
const attr = attributes[attrId]
|
const attr = attributes[attrId]
|
||||||
|
|
||||||
// chart history data
|
// chart history data
|
||||||
@@ -320,18 +327,18 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
|
|
||||||
const attrHistory = []
|
const attrHistory = []
|
||||||
for (const smart_result of smart_results){
|
for (const smartResult of smartResults) {
|
||||||
// attrHistory.push(this.getAttributeValue(smart_result.attrs[attrId]))
|
// attrHistory.push(this.getAttributeValue(smart_result.attrs[attrId]))
|
||||||
|
|
||||||
const chartDatapoint = {
|
const chartDatapoint = {
|
||||||
x: formatDate(smart_result.date, 'MMMM dd, yyyy - HH:mm', this.locale),
|
x: formatDate(smartResult.date, 'MMMM dd, yyyy - HH:mm', this.locale),
|
||||||
y: this.getAttributeValue(smart_result.attrs[attrId])
|
y: this.getAttributeValue(smartResult.attrs[attrId])
|
||||||
}
|
}
|
||||||
const attributeStatusName = this.getAttributeStatusName(smart_result.attrs[attrId].status)
|
const attributeStatusName = this.getAttributeStatusName(smartResult.attrs[attrId].status)
|
||||||
if(attributeStatusName === 'failed') {
|
if (attributeStatusName === 'failed') {
|
||||||
chartDatapoint['strokeColor'] = '#F05252'
|
chartDatapoint['strokeColor'] = '#F05252'
|
||||||
chartDatapoint['fillColor'] = '#F05252'
|
chartDatapoint['fillColor'] = '#F05252'
|
||||||
} else if (attributeStatusName === 'warn'){
|
} else if (attributeStatusName === 'warn') {
|
||||||
chartDatapoint['strokeColor'] = '#C27803'
|
chartDatapoint['strokeColor'] = '#C27803'
|
||||||
chartDatapoint['fillColor'] = '#C27803'
|
chartDatapoint['fillColor'] = '#C27803'
|
||||||
}
|
}
|
||||||
@@ -344,13 +351,15 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
attributes[attrId].chartData = [
|
attributes[attrId].chartData = [
|
||||||
{
|
{
|
||||||
name: 'chart-line-sparkline',
|
name: 'chart-line-sparkline',
|
||||||
data: attrHistory
|
// attrHistory needs to be reversed, so the newest data is on the right
|
||||||
|
// fixes #339
|
||||||
|
data: attrHistory.reverse()
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
// determine when to include the attributes in table.
|
// determine when to include the attributes in table.
|
||||||
|
|
||||||
if(!this.onlyCritical || this.onlyCritical && this.metadata[attr.attribute_id]?.critical || attr.value < attr.thresh){
|
if (!this.onlyCritical || this.onlyCritical && this.metadata[attr.attribute_id]?.critical || attr.value < attr.thresh) {
|
||||||
smartAttributeDataSource.push(attr)
|
smartAttributeDataSource.push(attr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,8 +371,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private _prepareChartData(): void
|
private _prepareChartData(): void {
|
||||||
{
|
|
||||||
|
|
||||||
// Account balance
|
// Account balance
|
||||||
this.commonSparklineOptions = {
|
this.commonSparklineOptions = {
|
||||||
@@ -392,7 +400,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
title: {
|
title: {
|
||||||
formatter: function(seriesName) {
|
formatter: (seriesName) => {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,27 +418,28 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private determineTheme(config:AppConfig): string {
|
private determineTheme(config: AppConfig): string {
|
||||||
if (config.theme === 'system') {
|
if (config.theme === 'system') {
|
||||||
return this.systemPrefersDark ? 'dark' : 'light'
|
return this.systemPrefersDark ? 'dark' : 'light'
|
||||||
} else {
|
} else {
|
||||||
return config.theme
|
return config.theme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------------------------------
|
||||||
// @ Public methods
|
// @ Public methods
|
||||||
// -----------------------------------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
toHex(decimalNumb){
|
toHex(decimalNumb: number | string): string {
|
||||||
return '0x' + Number(decimalNumb).toString(16).padStart(2, '0').toUpperCase()
|
return '0x' + Number(decimalNumb).toString(16).padStart(2, '0').toUpperCase()
|
||||||
}
|
}
|
||||||
toggleOnlyCritical(){
|
|
||||||
|
toggleOnlyCritical(): void {
|
||||||
this.onlyCritical = !this.onlyCritical
|
this.onlyCritical = !this.onlyCritical
|
||||||
this.smartAttributeDataSource.data = this._generateSmartAttributeTableDataSource(this.smart_results);
|
this.smartAttributeDataSource.data = this._generateSmartAttributeTableDataSource(this.smart_results);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openDialog() {
|
openDialog(): void {
|
||||||
const dialogRef = this.dialog.open(DetailSettingsComponent);
|
const dialogRef = this.dialog.open(DetailSettingsComponent);
|
||||||
|
|
||||||
dialogRef.afterClosed().subscribe(result => {
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
@@ -444,8 +453,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* @param index
|
* @param index
|
||||||
* @param item
|
* @param item
|
||||||
*/
|
*/
|
||||||
trackByFn(index: number, item: any): any
|
trackByFn(index: number, item: any): any {
|
||||||
{
|
|
||||||
return index;
|
return index;
|
||||||
// return item.id || index;
|
// return item.id || index;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import {Injectable} from '@angular/core';
|
||||||
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router';
|
||||||
import { Observable } from 'rxjs';
|
import {Observable} from 'rxjs';
|
||||||
import { DetailService } from 'app/modules/detail/detail.service';
|
import {DetailService} from 'app/modules/detail/detail.service';
|
||||||
|
import {DeviceDetailsResponseWrapper} from 'app/core/models/device-details-response-wrapper';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class DetailResolver implements Resolve<any>
|
export class DetailResolver implements Resolve<any> {
|
||||||
{
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
*
|
*
|
||||||
@@ -29,8 +29,7 @@ export class DetailResolver implements Resolve<any>
|
|||||||
* @param route
|
* @param route
|
||||||
* @param state
|
* @param state
|
||||||
*/
|
*/
|
||||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any>
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<DeviceDetailsResponseWrapper> {
|
||||||
{
|
|
||||||
return this._detailService.getData(route.params.wwn);
|
return this._detailService.getData(route.params.wwn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {HttpClient} from '@angular/common/http';
|
||||||
|
import {DetailService} from './detail.service';
|
||||||
|
import {of} from 'rxjs';
|
||||||
|
import {sda} from 'app/data/mock/device/details/sda'
|
||||||
|
import {DeviceDetailsResponseWrapper} from 'app/core/models/device-details-response-wrapper';
|
||||||
|
|
||||||
|
describe('DetailService', () => {
|
||||||
|
describe('#getData', () => {
|
||||||
|
let service: DetailService;
|
||||||
|
let httpClientSpy: jasmine.SpyObj<HttpClient>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
|
||||||
|
service = new DetailService(httpClientSpy);
|
||||||
|
});
|
||||||
|
it('should return getData() (HttpClient called once)', (done: DoneFn) => {
|
||||||
|
httpClientSpy.get.and.returnValue(of(sda));
|
||||||
|
|
||||||
|
service.getData('test').subscribe(value => {
|
||||||
|
expect(value).toBe(sda as DeviceDetailsResponseWrapper);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
expect(httpClientSpy.get.calls.count())
|
||||||
|
.withContext('one call')
|
||||||
|
.toBe(1);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import {Injectable} from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import {HttpClient} from '@angular/common/http';
|
||||||
import { BehaviorSubject, Observable } from 'rxjs';
|
import {BehaviorSubject, Observable} from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
import {tap} from 'rxjs/operators';
|
||||||
import { getBasePath } from 'app/app.routing';
|
import {getBasePath} from 'app/app.routing';
|
||||||
|
import {DeviceDetailsResponseWrapper} from 'app/core/models/device-details-response-wrapper';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class DetailService
|
export class DetailService {
|
||||||
{
|
|
||||||
// Observables
|
// Observables
|
||||||
private _data: BehaviorSubject<any>;
|
private _data: BehaviorSubject<DeviceDetailsResponseWrapper>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
@@ -19,8 +19,7 @@ export class DetailService
|
|||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private _httpClient: HttpClient
|
private _httpClient: HttpClient
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
// Set the private defaults
|
// Set the private defaults
|
||||||
this._data = new BehaviorSubject(null);
|
this._data = new BehaviorSubject(null);
|
||||||
}
|
}
|
||||||
@@ -32,8 +31,7 @@ export class DetailService
|
|||||||
/**
|
/**
|
||||||
* Getter for data
|
* Getter for data
|
||||||
*/
|
*/
|
||||||
get data$(): Observable<any>
|
get data$(): Observable<DeviceDetailsResponseWrapper> {
|
||||||
{
|
|
||||||
return this._data.asObservable();
|
return this._data.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +42,9 @@ export class DetailService
|
|||||||
/**
|
/**
|
||||||
* Get data
|
* Get data
|
||||||
*/
|
*/
|
||||||
getData(wwn): Observable<any>
|
getData(wwn): Observable<DeviceDetailsResponseWrapper> {
|
||||||
{
|
|
||||||
return this._httpClient.get(getBasePath() + `/api/device/${wwn}/details`).pipe(
|
return this._httpClient.get(getBasePath() + `/api/device/${wwn}/details`).pipe(
|
||||||
tap((response: any) => {
|
tap((response: DeviceDetailsResponseWrapper) => {
|
||||||
this._data.next(response);
|
this._data.next(response);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,146 @@
|
|||||||
import { DeviceStatusPipe } from './device-status.pipe';
|
import {DeviceStatusPipe} from './device-status.pipe';
|
||||||
|
import {MetricsStatusThreshold} from '../core/config/app.config';
|
||||||
|
import {DeviceModel} from '../core/models/device-model';
|
||||||
|
|
||||||
describe('DeviceStatusPipe', () => {
|
describe('DeviceStatusPipe', () => {
|
||||||
it('create an instance', () => {
|
it('create an instance', () => {
|
||||||
const pipe = new DeviceStatusPipe();
|
const pipe = new DeviceStatusPipe();
|
||||||
expect(pipe).toBeTruthy();
|
expect(pipe).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#deviceStatusForModelWithThreshold', () => {
|
||||||
|
it('if healthy device, should be passing', () => {
|
||||||
|
expect(DeviceStatusPipe.deviceStatusForModelWithThreshold(
|
||||||
|
{device_status: 0} as DeviceModel,
|
||||||
|
true,
|
||||||
|
MetricsStatusThreshold.Both
|
||||||
|
)).toBe('passed')
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if device with no smart data, should be unknown', () => {
|
||||||
|
expect(DeviceStatusPipe.deviceStatusForModelWithThreshold(
|
||||||
|
{device_status: 0} as DeviceModel,
|
||||||
|
false,
|
||||||
|
MetricsStatusThreshold.Both
|
||||||
|
)).toBe('unknown')
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
'deviceStatus': 10000, // invalid status
|
||||||
|
'hasSmartResults': false,
|
||||||
|
'threshold': MetricsStatusThreshold.Smart,
|
||||||
|
'includeReason': false,
|
||||||
|
'result': 'unknown'
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
'deviceStatus': 1,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Smart,
|
||||||
|
'includeReason': false,
|
||||||
|
'result': 'failed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'deviceStatus': 1,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Scrutiny,
|
||||||
|
'includeReason': false,
|
||||||
|
'result': 'passed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'deviceStatus': 1,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Both,
|
||||||
|
'includeReason': false,
|
||||||
|
'result': 'failed'
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
'deviceStatus': 2,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Smart,
|
||||||
|
'includeReason': false,
|
||||||
|
'result': 'passed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'deviceStatus': 2,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Scrutiny,
|
||||||
|
'includeReason': false,
|
||||||
|
'result': 'failed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'deviceStatus': 2,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Both,
|
||||||
|
'includeReason': false,
|
||||||
|
'result': 'failed'
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
'deviceStatus': 3,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Smart,
|
||||||
|
'includeReason': false,
|
||||||
|
'result': 'failed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'deviceStatus': 3,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Scrutiny,
|
||||||
|
'includeReason': false,
|
||||||
|
'result': 'failed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'deviceStatus': 3,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Both,
|
||||||
|
'includeReason': false,
|
||||||
|
'result': 'failed'
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
'deviceStatus': 3,
|
||||||
|
'hasSmartResults': false,
|
||||||
|
'threshold': MetricsStatusThreshold.Smart,
|
||||||
|
'includeReason': true,
|
||||||
|
'result': 'unknown'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'deviceStatus': 3,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Smart,
|
||||||
|
'includeReason': true,
|
||||||
|
'result': 'failed: smart'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'deviceStatus': 3,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Scrutiny,
|
||||||
|
'includeReason': true,
|
||||||
|
'result': 'failed: scrutiny'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'deviceStatus': 3,
|
||||||
|
'hasSmartResults': true,
|
||||||
|
'threshold': MetricsStatusThreshold.Both,
|
||||||
|
'includeReason': true,
|
||||||
|
'result': 'failed: both'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
testCases.forEach((test, index) => {
|
||||||
|
it(`if device with status (${test.deviceStatus}), hasSmartResults(${test.hasSmartResults}) and threshold (${test.threshold}), should be ${test.result}`, () => {
|
||||||
|
expect(DeviceStatusPipe.deviceStatusForModelWithThreshold(
|
||||||
|
{device_status: test.deviceStatus} as DeviceModel,
|
||||||
|
test.hasSmartResults,
|
||||||
|
test.threshold,
|
||||||
|
test.includeReason
|
||||||
|
)).toBe(test.result)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,71 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import {Pipe, PipeTransform} from '@angular/core';
|
||||||
|
import {MetricsStatusThreshold} from '../core/config/app.config';
|
||||||
|
import {DeviceModel} from '../core/models/device-model';
|
||||||
|
|
||||||
|
const DEVICE_STATUS_NAMES: { [key: number]: string } = {
|
||||||
|
0: 'passed',
|
||||||
|
1: 'failed',
|
||||||
|
2: 'failed',
|
||||||
|
3: 'failed'
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEVICE_STATUS_NAMES_WITH_REASON: { [key: number]: string } = {
|
||||||
|
0: 'passed',
|
||||||
|
1: 'failed: smart',
|
||||||
|
2: 'failed: scrutiny',
|
||||||
|
3: 'failed: both'
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'deviceStatus'
|
name: 'deviceStatus'
|
||||||
})
|
})
|
||||||
export class DeviceStatusPipe implements PipeTransform {
|
export class DeviceStatusPipe implements PipeTransform {
|
||||||
|
|
||||||
transform(deviceStatusFlag: number): string {
|
|
||||||
if(deviceStatusFlag === 0){
|
static deviceStatusForModelWithThreshold(
|
||||||
return 'passed'
|
deviceModel: DeviceModel,
|
||||||
} else if(deviceStatusFlag === 3){
|
hasSmartResults: boolean = true,
|
||||||
return 'failed: both'
|
threshold: MetricsStatusThreshold = MetricsStatusThreshold.Both,
|
||||||
} else if(deviceStatusFlag === 2) {
|
includeReason: boolean = false
|
||||||
return 'failed: scrutiny'
|
): string {
|
||||||
} else if(deviceStatusFlag === 1) {
|
// no smart data, so treat the device status as unknown
|
||||||
return 'failed: smart'
|
if (!hasSmartResults) {
|
||||||
}
|
return 'unknown'
|
||||||
return 'unknown'
|
}
|
||||||
}
|
|
||||||
|
let statusNameLookup = DEVICE_STATUS_NAMES
|
||||||
|
if (includeReason) {
|
||||||
|
statusNameLookup = DEVICE_STATUS_NAMES_WITH_REASON
|
||||||
|
}
|
||||||
|
// determine the device status, by comparing it against the allowed threshold
|
||||||
|
// tslint:disable-next-line:no-bitwise
|
||||||
|
const deviceStatus = deviceModel.device_status & threshold
|
||||||
|
return statusNameLookup[deviceStatus]
|
||||||
|
}
|
||||||
|
|
||||||
|
// static deviceStatusForModelWithThreshold(deviceModel: DeviceModel | any, threshold: MetricsStatusThreshold): string {
|
||||||
|
// // tslint:disable-next-line:no-bitwise
|
||||||
|
// const deviceStatus = deviceModel?.device_status & threshold
|
||||||
|
// if(deviceStatus === 0){
|
||||||
|
// return 'passed'
|
||||||
|
// } else if(deviceStatus === 3){
|
||||||
|
// return 'failed: both'
|
||||||
|
// } else if(deviceStatus === 2) {
|
||||||
|
// return 'failed: scrutiny'
|
||||||
|
// } else if(deviceStatus === 1) {
|
||||||
|
// return 'failed: smart'
|
||||||
|
// }
|
||||||
|
// return 'unknown'
|
||||||
|
// }
|
||||||
|
|
||||||
|
transform(
|
||||||
|
deviceModel: DeviceModel,
|
||||||
|
hasSmartResults: boolean = true,
|
||||||
|
threshold: MetricsStatusThreshold = MetricsStatusThreshold.Both,
|
||||||
|
includeReason: boolean = false
|
||||||
|
): string {
|
||||||
|
return DeviceStatusPipe.deviceStatusForModelWithThreshold(deviceModel, hasSmartResults, threshold, includeReason)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,151 @@
|
|||||||
import { DeviceTitlePipe } from './device-title.pipe';
|
import {DeviceTitlePipe} from './device-title.pipe';
|
||||||
|
import {DeviceModel} from 'app/core/models/device-model';
|
||||||
|
|
||||||
describe('DeviceTitlePipe', () => {
|
describe('DeviceTitlePipe', () => {
|
||||||
it('create an instance', () => {
|
it('create an instance', () => {
|
||||||
const pipe = new DeviceTitlePipe();
|
const pipe = new DeviceTitlePipe();
|
||||||
expect(pipe).toBeTruthy();
|
expect(pipe).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#deviceTitleForType', () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
'device': {
|
||||||
|
'device_name': 'sda',
|
||||||
|
'device_type': 'ata',
|
||||||
|
'model_name': 'Samsung',
|
||||||
|
},
|
||||||
|
'titleType': 'name',
|
||||||
|
'result': '/dev/sda - Samsung'
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'device_name': 'nvme0',
|
||||||
|
'device_type': 'nvme',
|
||||||
|
'model_name': 'Samsung',
|
||||||
|
},
|
||||||
|
'titleType': 'name',
|
||||||
|
'result': '/dev/nvme0 - nvme - Samsung'
|
||||||
|
},{
|
||||||
|
'device': {},
|
||||||
|
'titleType': 'serial_id',
|
||||||
|
'result': ''
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'device_serial_id': 'ata-WDC_WD140EDFZ-11AXXXXX_9RXXXXXX',
|
||||||
|
},
|
||||||
|
'titleType': 'serial_id',
|
||||||
|
'result': '/by-id/ata-WDC_WD140EDFZ-11AXXXXX_9RXXXXXX'
|
||||||
|
},{
|
||||||
|
'device': {},
|
||||||
|
'titleType': 'uuid',
|
||||||
|
'result': ''
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'device_uuid': 'abcdef-1234-4567-8901'
|
||||||
|
},
|
||||||
|
'titleType': 'uuid',
|
||||||
|
'result': '/by-uuid/abcdef-1234-4567-8901'
|
||||||
|
},{
|
||||||
|
'device': {},
|
||||||
|
'titleType': 'label',
|
||||||
|
'result': ''
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'label': 'custom-device-label'
|
||||||
|
},
|
||||||
|
'titleType': 'label',
|
||||||
|
'result': 'custom-device-label'
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'device_label': 'drive-volume-label'
|
||||||
|
},
|
||||||
|
'titleType': 'label',
|
||||||
|
'result': '/by-label/drive-volume-label'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
testCases.forEach((test, index) => {
|
||||||
|
it(`should correctly format device title ${JSON.stringify(test.device)}. (testcase: ${index + 1})`, () => {
|
||||||
|
// test
|
||||||
|
const formatted = DeviceTitlePipe.deviceTitleForType(test.device as DeviceModel, test.titleType)
|
||||||
|
expect(formatted).toEqual(test.result);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('#deviceTitleWithFallback',() => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
'device': {
|
||||||
|
'device_name': 'sda',
|
||||||
|
'device_type': 'ata',
|
||||||
|
'model_name': 'Samsung',
|
||||||
|
},
|
||||||
|
'titleType': 'name',
|
||||||
|
'result': '/dev/sda - Samsung'
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'device_name': 'nvme0',
|
||||||
|
'device_type': 'nvme',
|
||||||
|
'model_name': 'Samsung',
|
||||||
|
},
|
||||||
|
'titleType': 'name',
|
||||||
|
'result': '/dev/nvme0 - nvme - Samsung'
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'device_name': 'fallback',
|
||||||
|
'device_type': 'ata',
|
||||||
|
'model_name': 'fallback',
|
||||||
|
},
|
||||||
|
'titleType': 'serial_id',
|
||||||
|
'result': '/dev/fallback - fallback'
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'device_serial_id': 'ata-WDC_WD140EDFZ-11AXXXXX_9RXXXXXX',
|
||||||
|
},
|
||||||
|
'titleType': 'serial_id',
|
||||||
|
'result': '/by-id/ata-WDC_WD140EDFZ-11AXXXXX_9RXXXXXX'
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'device_name': 'fallback',
|
||||||
|
'device_type': 'ata',
|
||||||
|
'model_name': 'fallback',
|
||||||
|
},
|
||||||
|
'titleType': 'uuid',
|
||||||
|
'result': '/dev/fallback - fallback'
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'device_uuid': 'abcdef-1234-4567-8901'
|
||||||
|
},
|
||||||
|
'titleType': 'uuid',
|
||||||
|
'result': '/by-uuid/abcdef-1234-4567-8901'
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'device_name': 'fallback',
|
||||||
|
'device_type': 'ata',
|
||||||
|
'model_name': 'fallback',
|
||||||
|
},
|
||||||
|
'titleType': 'label',
|
||||||
|
'result': '/dev/fallback - fallback'
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'label': 'custom-device-label'
|
||||||
|
},
|
||||||
|
'titleType': 'label',
|
||||||
|
'result': 'custom-device-label'
|
||||||
|
},{
|
||||||
|
'device': {
|
||||||
|
'device_label': 'drive-volume-label'
|
||||||
|
},
|
||||||
|
'titleType': 'label',
|
||||||
|
'result': '/by-label/drive-volume-label'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
testCases.forEach((test, index) => {
|
||||||
|
it(`should correctly format device title ${JSON.stringify(test.device)}. (testcase: ${index + 1})`, () => {
|
||||||
|
// test
|
||||||
|
const formatted = DeviceTitlePipe.deviceTitleWithFallback(test.device as DeviceModel, test.titleType)
|
||||||
|
expect(formatted).toEqual(test.result);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import {Pipe, PipeTransform} from '@angular/core';
|
||||||
|
import {DeviceModel} from 'app/core/models/device-model';
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'deviceTitle'
|
name: 'deviceTitle'
|
||||||
})
|
})
|
||||||
export class DeviceTitlePipe implements PipeTransform {
|
export class DeviceTitlePipe implements PipeTransform {
|
||||||
|
|
||||||
static deviceTitleForType(device: any, titleType: string): string {
|
static deviceTitleForType(device: DeviceModel, titleType: string): string {
|
||||||
const titleParts = []
|
const titleParts = []
|
||||||
switch(titleType){
|
switch(titleType){
|
||||||
case 'name':
|
case 'name':
|
||||||
@@ -35,7 +36,7 @@ export class DeviceTitlePipe implements PipeTransform {
|
|||||||
return titleParts.join(' - ')
|
return titleParts.join(' - ')
|
||||||
}
|
}
|
||||||
|
|
||||||
static deviceTitleWithFallback(device, titleType: string): string {
|
static deviceTitleWithFallback(device: DeviceModel, titleType: string): string {
|
||||||
console.log(`Displaying Device ${device.wwn} with: ${titleType}`)
|
console.log(`Displaying Device ${device.wwn} with: ${titleType}`)
|
||||||
const titleParts = []
|
const titleParts = []
|
||||||
if (device.host_id) titleParts.push(device.host_id)
|
if (device.host_id) titleParts.push(device.host_id)
|
||||||
@@ -47,7 +48,7 @@ export class DeviceTitlePipe implements PipeTransform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
transform(device: any, titleType: string = 'name'): string {
|
transform(device: DeviceModel, titleType: string = 'name'): string {
|
||||||
return DeviceTitlePipe.deviceTitleWithFallback(device, titleType)
|
return DeviceTitlePipe.deviceTitleWithFallback(device, titleType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {FileSizePipe} from './file-size.pipe';
|
||||||
|
|
||||||
|
describe('FileSizePipe', () => {
|
||||||
|
it('create an instance', () => {
|
||||||
|
const pipe = new FileSizePipe();
|
||||||
|
expect(pipe).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#transform',() => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
'bytes': 1500,
|
||||||
|
'si': false,
|
||||||
|
'result': '1.5 KiB'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'bytes': 1500,
|
||||||
|
'si': true,
|
||||||
|
'result': '1.5 kB'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'bytes': 5000,
|
||||||
|
'si': false,
|
||||||
|
'result': '4.9 KiB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'bytes': 5000,
|
||||||
|
'si': true,
|
||||||
|
'result': '5.0 kB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'bytes': 999_949,
|
||||||
|
'si': false,
|
||||||
|
'result': '976.5 KiB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'bytes': 999_949,
|
||||||
|
'si': true,
|
||||||
|
'result': '999.9 kB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'bytes': 999_950,
|
||||||
|
'si': true,
|
||||||
|
'result': '1.0 MB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'bytes': 1_551_859_712,
|
||||||
|
'si': false,
|
||||||
|
'result': '1.4 GiB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'bytes': 2_100_000_000,
|
||||||
|
'si': false,
|
||||||
|
'result': '2.0 GiB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'bytes': 2_100_000_000,
|
||||||
|
'si': true,
|
||||||
|
'result': '2.1 GB',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
testCases.forEach((test, index) => {
|
||||||
|
it(`should correctly format bytes ${test.bytes}. (testcase: ${index + 1})`, () => {
|
||||||
|
// test
|
||||||
|
const pipe = new FileSizePipe();
|
||||||
|
const formatted = pipe.transform(test.bytes, test.si)
|
||||||
|
expect(formatted).toEqual(test.result);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
});
|
||||||
@@ -1,75 +1,27 @@
|
|||||||
/**
|
import {Pipe, PipeTransform} from '@angular/core';
|
||||||
* @license
|
|
||||||
* Copyright (c) 2019 Jonathan Catmull.
|
|
||||||
*
|
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
* of this software and associated documentation files (the "Software"), to deal
|
|
||||||
* in the Software without restriction, including without limitation the rights
|
|
||||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
* copies of the Software, and to permit persons to whom the Software is
|
|
||||||
* furnished to do so, subject to the following conditions:
|
|
||||||
*
|
|
||||||
* The above copyright notice and this permission notice shall be included in all
|
|
||||||
* copies or substantial portions of the Software.
|
|
||||||
*
|
|
||||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
* SOFTWARE.
|
|
||||||
*/
|
|
||||||
import { Pipe, PipeTransform } from '@angular/core';
|
|
||||||
|
|
||||||
type unit = 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB';
|
@Pipe({name: 'fileSize'})
|
||||||
type unitPrecisionMap = {
|
|
||||||
[u in unit]: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultPrecisionMap: unitPrecisionMap = {
|
|
||||||
bytes: 0,
|
|
||||||
KB: 0,
|
|
||||||
MB: 1,
|
|
||||||
GB: 1,
|
|
||||||
TB: 2,
|
|
||||||
PB: 2
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Convert bytes into largest possible unit.
|
|
||||||
* Takes an precision argument that can be a number or a map for each unit.
|
|
||||||
* Usage:
|
|
||||||
* bytes | fileSize:precision
|
|
||||||
* @example
|
|
||||||
* // returns 1 KB
|
|
||||||
* {{ 1500 | fileSize }}
|
|
||||||
* @example
|
|
||||||
* // returns 2.1 GB
|
|
||||||
* {{ 2100000000 | fileSize }}
|
|
||||||
* @example
|
|
||||||
* // returns 1.46 KB
|
|
||||||
* {{ 1500 | fileSize:2 }}
|
|
||||||
*/
|
|
||||||
@Pipe({ name: 'fileSize' })
|
|
||||||
export class FileSizePipe implements PipeTransform {
|
export class FileSizePipe implements PipeTransform {
|
||||||
private readonly units: unit[] = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
||||||
|
|
||||||
transform(bytes: number = 0, precision: number | unitPrecisionMap = defaultPrecisionMap): string {
|
transform(bytes: number = 0, si = false, dp = 1): string {
|
||||||
if (isNaN(parseFloat(String(bytes))) || !isFinite(bytes)) return '?';
|
const thresh = si ? 1000 : 1024;
|
||||||
|
|
||||||
let unitIndex = 0;
|
if (Math.abs(bytes) < thresh) {
|
||||||
|
return bytes + ' B';
|
||||||
while (bytes >= 1024) {
|
|
||||||
bytes /= 1024;
|
|
||||||
unitIndex++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const unit = this.units[unitIndex];
|
const units = si
|
||||||
|
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
|
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||||
|
let u = -1;
|
||||||
|
const r = 10 ** dp;
|
||||||
|
|
||||||
if (typeof precision === 'number') {
|
do {
|
||||||
return `${bytes.toFixed(+precision)} ${unit}`;
|
bytes /= thresh;
|
||||||
}
|
++u;
|
||||||
return `${bytes.toFixed(precision[unit])} ${unit}`;
|
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||||
|
|
||||||
|
|
||||||
|
return bytes.toFixed(dp) + ' ' + units[u];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user