diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1310337..211a579 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,11 +3,25 @@ name: CI on: [pull_request] jobs: - test: - name: Test + test-frontend: + 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 container: ghcr.io/packagrio/packagr:latest-golang - # Service containers to run with `build` (Required for end-to-end testing) services: influxdb: @@ -22,7 +36,6 @@ jobs: ports: - 8086:8086 env: - PROJECT_PATH: /go/src/github.com/analogj/scrutiny STATIC: true steps: - name: Git @@ -32,16 +45,36 @@ jobs: git --version - name: Checkout uses: actions/checkout@v2 - - name: Test + - name: Test Backend run: | 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 with: - files: ${{ github.workspace }}/coverage.txt + files: ${{ github.workspace }}/coverage.txt,${{ github.workspace }}/lcov.info flags: unittests fail_ci_if_error: true verbose: true + build: name: Build ${{ matrix.cfg.goos }}/${{ matrix.cfg.goarch }} runs-on: ${{ matrix.cfg.on }} @@ -66,6 +99,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + - uses: actions/setup-go@v3 + with: + go-version: '^1.18.3' - name: Build Binaries run: | make binary-clean binary-all diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c665e9c..5a6ad71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,8 +9,9 @@ Depending on the functionality you are adding, you may need to setup a developme # Modifying the Scrutiny Backend Server (API) -1. install the [Go runtime](https://go.dev/doc/install) (v1.17+) -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` +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` 3. create a `scrutiny.yaml` config file ```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, 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/) 3. create a `scrutiny.yaml` config file ```yaml diff --git a/Makefile b/Makefile index afb2d84..edc14da 100644 --- a/Makefile +++ b/Makefile @@ -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. +.SHELLFLAGS = -ec ######################################################################################################################## # Global Env Settings @@ -89,6 +90,10 @@ ifneq ($(OS),Windows_NT) ./$(WEB_BINARY_NAME) || true endif +######################################################################################################################## +# Binary +######################################################################################################################## + .PHONY: binary-frontend # reduce logging, disable angular-cli analytics for ci environment binary-frontend: export NPM_CONFIG_LOGLEVEL = warn @@ -100,6 +105,12 @@ binary-frontend: npm ci 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 diff --git a/collector/pkg/collector/metrics.go b/collector/pkg/collector/metrics.go index 415516b..5d453dd 100644 --- a/collector/pkg/collector/metrics.go +++ b/collector/pkg/collector/metrics.go @@ -9,6 +9,7 @@ import ( "github.com/analogj/scrutiny/collector/pkg/detect" "github.com/analogj/scrutiny/collector/pkg/errors" "github.com/analogj/scrutiny/collector/pkg/models" + "github.com/samber/lo" "github.com/sirupsen/logrus" "net/url" "os" @@ -56,11 +57,16 @@ func (mc *MetricsCollector) Run() error { Logger: mc.logger, Config: mc.config, } - detectedStorageDevices, err := deviceDetector.Start() + rawDetectedStorageDevices, err := deviceDetector.Start() if err != nil { 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") jsonObj, _ := json.Marshal(detectedStorageDevices) mc.logger.Debugf("Detected devices: %v", string(jsonObj)) diff --git a/docker/Dockerfile b/docker/Dockerfile index c6074da..7d35bef 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 COPY . /go/src/github.com/analogj/scrutiny diff --git a/docker/Dockerfile.collector b/docker/Dockerfile.collector index 9c614f8..c4553fd 100644 --- a/docker/Dockerfile.collector +++ b/docker/Dockerfile.collector @@ -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 diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web index ff03115..8d1192c 100644 --- a/docker/Dockerfile.web +++ b/docker/Dockerfile.web @@ -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 diff --git a/docs/SUPPORTED_NAS_OS.md b/docs/SUPPORTED_NAS_OS.md index 455392f..aae1e52 100644 --- a/docs/SUPPORTED_NAS_OS.md +++ b/docs/SUPPORTED_NAS_OS.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). -Once a guide is created (in `docs/guides/`) it will be linked here. +These are the officially supported NAS OS's (with documentation and setup guides). Once a guide is created ( +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) - [ ] ESXI - [ ] Proxmox -- [x] Synology(./INSTALL_SYNOLOGY_COLLECTOR.md) +- [x] [Synology](./INSTALL_SYNOLOGY_COLLECTOR.md) - [ ] OMV - [ ] Amahi - [ ] Running in a LXC container - [x] [PFSense](./INSTALL_UNRAID.md) -- [ ] QNAP -- [ ] RockStor - +- [x] QNAP +- [x] [RockStor](https://rockstor.com/docs/interface/docker-based-rock-ons/scrutiny.html) +- [ ] Solaris/OmniOS CE Support +- [ ] Kubernetes diff --git a/docs/TROUBLESHOOTING_DEVICE_COLLECTOR.md b/docs/TROUBLESHOOTING_DEVICE_COLLECTOR.md index dbea27a..d512aee 100644 --- a/docs/TROUBLESHOOTING_DEVICE_COLLECTOR.md +++ b/docs/TROUBLESHOOTING_DEVICE_COLLECTOR.md @@ -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` 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). > 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 +### 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 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, -but you can look at the table (and associated links) below to debug `smartctl`. +`smartctl` (not Scrutiny) exited with an error code. Scrutiny will attempt to print a helpful error message to help you +debug, but you can look at the table (and associated links) below to debug `smartctl`. > 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 -> smartctl is 0 (all bits turned off). If a problem occurs, or an error, potential error, or fault is detected, then +> 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 > 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. > diff --git a/docs/TROUBLESHOOTING_INFLUXDB.md b/docs/TROUBLESHOOTING_INFLUXDB.md index 437042b..22b52c6 100644 --- a/docs/TROUBLESHOOTING_INFLUXDB.md +++ b/docs/TROUBLESHOOTING_INFLUXDB.md @@ -66,12 +66,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), this usually related to either: -- Upgrading from the LSIO Scrutiny image to the Official Scrutiny image, without removing LSIO specific environmental variables - - 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. -- 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`) +- Upgrading from the LSIO Scrutiny image to the Official Scrutiny image, without removing LSIO specific environmental + variables + - 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. +- 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: - 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 + +## 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 + +
+ Click to expand! + +```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 +``` + +
+ +### Create placeholder tasks + +
+ Click to expand! + +```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 + +``` + +
+ +### Create InfluxDB API Token + +
+ Click to expand! + +```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 +``` + +
+ +### 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 + diff --git a/go.mod b/go.mod index 91e6062..5a6daf3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/analogj/scrutiny -go 1.17 +go 1.18 require ( 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/jaypipes/ghw v0.6.1 github.com/mitchellh/mapstructure v1.2.2 + github.com/samber/lo v1.25.0 github.com/sirupsen/logrus v1.4.2 github.com/spf13/viper v1.7.0 github.com/stretchr/testify v1.7.1 @@ -23,7 +24,6 @@ require ( require ( 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/davecgh/go-spew v1.1.1 // 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/ugorji/go/codec v1.1.7 // 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/sys v0.0.0-20220405052023-b1e9470b6e64 // 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/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // 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 howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect modernc.org/libc v1.16.8 // indirect diff --git a/go.sum b/go.sum index c8c79fc..e2bdeaa 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= 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/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/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 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.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.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/go.mod h1:8/LTPeDLaklcUjgSQBHbhBF1ibKAFxzS5o+H7USfMSA= 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/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 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.6 h1:11TGpSHY7Esh/i/qnq02Jo5oVrI1Gue8Slbq0ujPZFQ= 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/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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/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= @@ -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/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= 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/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.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= 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/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-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-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-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 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/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 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-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 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/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.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 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-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/go.mod h1:KtqSthtg55lFp3S5kUXqlGaelnWpKitn4k1xZTnoiPw= gorm.io/driver/postgres v1.0.0 h1:Yh4jyFQ0a7F+JPU0Gtiam/eKmpT/XFc1FKxotGqc6FM= diff --git a/webapp/backend/pkg/database/scrutiny_repository.go b/webapp/backend/pkg/database/scrutiny_repository.go index 28e0e2d..81f2316 100644 --- a/webapp/backend/pkg/database/scrutiny_repository.go +++ b/webapp/backend/pkg/database/scrutiny_repository.go @@ -242,21 +242,29 @@ func (sr *scrutinyRepository) EnsureBuckets(ctx context.Context, org *domain.Org //create buckets (used for downsampling) 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) _, err := sr.influxClient.BucketsAPI().CreateBucketWithName(ctx, org, weeklyBucket, weeklyBucketRetentionRule) if err != nil { 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")) - 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) _, err := sr.influxClient.BucketsAPI().CreateBucketWithName(ctx, org, monthlyBucket, monthlyBucketRetentionRule) if err != nil { 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")) diff --git a/webapp/backend/pkg/database/scrutiny_repository_migrations.go b/webapp/backend/pkg/database/scrutiny_repository_migrations.go index ab3296e..bb40add 100644 --- a/webapp/backend/pkg/database/scrutiny_repository_migrations.go +++ b/webapp/backend/pkg/database/scrutiny_repository_migrations.go @@ -267,6 +267,14 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error { 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 + }, + }, }) if err := m.Migrate(); err != nil { diff --git a/webapp/backend/pkg/database/scrutiny_repository_tasks.go b/webapp/backend/pkg/database/scrutiny_repository_tasks.go index 079caff..82b6040 100644 --- a/webapp/backend/pkg/database/scrutiny_repository_tasks.go +++ b/webapp/backend/pkg/database/scrutiny_repository_tasks.go @@ -11,35 +11,71 @@ import ( //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func (sr *scrutinyRepository) EnsureTasks(ctx context.Context, orgID string) error { 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 { //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 { 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" + monthlyTaskScript := sr.DownsampleScript("monthly", monthlyTaskName, "30 1 1 * *") 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 - _, err := sr.influxTaskApi.CreateTaskWithCron(ctx, monthlyTaskName, sr.DownsampleScript("monthly"), "30 1 1 * *", orgID) + _, err := sr.influxTaskApi.CreateTaskByFlux(ctx, monthlyTaskScript, orgID) if err != nil { 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" + 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 { //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 { 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 } -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 destBucket string // the destination for the aggregated data var rangeStart string @@ -88,30 +124,37 @@ func (sr *scrutinyRepository) DownsampleScript(aggregationType string) string { */ return fmt.Sprintf(` - sourceBucket = "%s" - rangeStart = %s - rangeEnd = %s - aggWindow = %s - destBucket = "%s" - destOrg = "%s" +option task = { + name: "%s", + cron: "%s", +} - 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) +sourceBucket = "%s" +rangeStart = %s +rangeEnd = %s +aggWindow = %s +destBucket = "%s" +destOrg = "%s" - temp_data = from(bucket: sourceBucket) - |> range(start: rangeStart, stop: rangeEnd) - |> filter(fn: (r) => r["_measurement"] == "temp") - |> group(columns: ["device_wwn"]) - |> toInt() +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) - temp_data - |> aggregateWindow(fn: mean, every: aggWindow, 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) `, + name, + cron, sourceBucket, rangeStart, rangeEnd, diff --git a/webapp/backend/pkg/database/scrutiny_repository_tasks_test.go b/webapp/backend/pkg/database/scrutiny_repository_tasks_test.go new file mode 100644 index 0000000..e1e5e5c --- /dev/null +++ b/webapp/backend/pkg/database/scrutiny_repository_tasks_test.go @@ -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) +} diff --git a/webapp/backend/pkg/database/scrutiny_repository_temperature_test.go b/webapp/backend/pkg/database/scrutiny_repository_temperature_test.go new file mode 100644 index 0000000..7751ddf --- /dev/null +++ b/webapp/backend/pkg/database/scrutiny_repository_temperature_test.go @@ -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) +} diff --git a/webapp/backend/pkg/web/handler/get_devices_summary.go b/webapp/backend/pkg/web/handler/get_devices_summary.go index 8eb392f..56e3eb5 100644 --- a/webapp/backend/pkg/web/handler/get_devices_summary.go +++ b/webapp/backend/pkg/web/handler/get_devices_summary.go @@ -18,6 +18,7 @@ func GetDevicesSummary(c *gin.Context) { return } + //this must match DeviceSummaryWrapper (webapp/backend/pkg/models/device_summary.go) c.JSON(http.StatusOK, gin.H{ "success": true, "data": map[string]interface{}{ diff --git a/webapp/backend/pkg/web/handler/register_devices.go b/webapp/backend/pkg/web/handler/register_devices.go index e1ddf95..cb0c59b 100644 --- a/webapp/backend/pkg/web/handler/register_devices.go +++ b/webapp/backend/pkg/web/handler/register_devices.go @@ -4,6 +4,7 @@ import ( "github.com/analogj/scrutiny/webapp/backend/pkg/database" "github.com/analogj/scrutiny/webapp/backend/pkg/models" "github.com/gin-gonic/gin" + "github.com/samber/lo" "github.com/sirupsen/logrus" "net/http" ) @@ -22,8 +23,13 @@ func RegisterDevices(c *gin.Context) { 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{} - for _, dev := range collectorDeviceWrapper.Data { + for _, dev := range detectedStorageDevices { //insert devices into DB (and update specified columns if device is already registered) // update device fields that may change: (DeviceType, HostID) if err := deviceRepo.RegisterDevice(c, dev); err != nil { @@ -40,7 +46,7 @@ func RegisterDevices(c *gin.Context) { } else { c.JSON(http.StatusOK, models.DeviceWrapper{ Success: true, - Data: collectorDeviceWrapper.Data, + Data: detectedStorageDevices, }) return } diff --git a/webapp/backend/pkg/web/handler/upload_device_metrics.go b/webapp/backend/pkg/web/handler/upload_device_metrics.go index e893366..d27f66b 100644 --- a/webapp/backend/pkg/web/handler/upload_device_metrics.go +++ b/webapp/backend/pkg/web/handler/upload_device_metrics.go @@ -20,6 +20,10 @@ func UploadDeviceMetrics(c *gin.Context) { //appConfig := c.MustGet("CONFIG").(config.Interface) + if c.Param("wwn") == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false}) + } + var collectorSmartData collector.SmartInfo err := c.BindJSON(&collectorSmartData) if err != nil { diff --git a/webapp/frontend/.gitignore b/webapp/frontend/.gitignore index 10fbf55..dd9d262 100644 --- a/webapp/frontend/.gitignore +++ b/webapp/frontend/.gitignore @@ -46,3 +46,5 @@ testem.log Thumbs.db /dist + +/coverage diff --git a/webapp/frontend/angular.json b/webapp/frontend/angular.json index 004e2cd..6ea760f 100644 --- a/webapp/frontend/angular.json +++ b/webapp/frontend/angular.json @@ -91,6 +91,7 @@ }, "test": { "builder": "@angular-devkit/build-angular:karma", + "defaultConfiguration": "production", "options": { "main": "src/test.ts", "polyfills": "src/polyfills.ts", @@ -101,10 +102,22 @@ "src/favicon-32x32.png", "src/assets" ], + "stylePreprocessorOptions": { + "includePaths": [ + "src/@treo/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": { diff --git a/webapp/frontend/karma.conf.js b/webapp/frontend/karma.conf.js index 04347ed..4ccca41 100644 --- a/webapp/frontend/karma.conf.js +++ b/webapp/frontend/karma.conf.js @@ -17,8 +17,8 @@ module.exports = function (config) clearContext: false // leave Jasmine Spec Runner output visible in browser }, coverageIstanbulReporter: { - dir : require('path').join(__dirname, './coverage/treo'), - reports : ['html', 'lcovonly', 'text-summary'], + dir: require('path').join(__dirname, './coverage'), + reports: ['html', 'lcovonly', 'text-summary'], fixWebpackSourcePaths: true }, reporters : ['progress', 'kjhtml'], diff --git a/webapp/frontend/src/app/core/config/app.config.ts b/webapp/frontend/src/app/core/config/app.config.ts index e98a6c8..74143c5 100644 --- a/webapp/frontend/src/app/core/config/app.config.ts +++ b/webapp/frontend/src/app/core/config/app.config.ts @@ -1,22 +1,28 @@ -import { Layout } from 'app/layout/layout.types'; +import {Layout} from 'app/layout/layout.types'; // Theme type 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' + /** * AppConfig interface. Update this interface to strictly type your config * object. */ -export interface AppConfig -{ +export interface AppConfig { theme: Theme; layout: Layout; // Dashboard options - dashboardDisplay: string; - dashboardSort: string; + dashboardDisplay: DashboardDisplay; + dashboardSort: DashboardSort; - temperatureUnit: string; + temperatureUnit: TemperatureUnit; } /** diff --git a/webapp/frontend/src/app/core/models/device-details-response-wrapper.ts b/webapp/frontend/src/app/core/models/device-details-response-wrapper.ts new file mode 100644 index 0000000..610af90 --- /dev/null +++ b/webapp/frontend/src/app/core/models/device-details-response-wrapper.ts @@ -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 }; +} diff --git a/webapp/frontend/src/app/core/models/device-model.ts b/webapp/frontend/src/app/core/models/device-model.ts new file mode 100644 index 0000000..7613c3f --- /dev/null +++ b/webapp/frontend/src/app/core/models/device-model.ts @@ -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; +} diff --git a/webapp/frontend/src/app/core/models/device-summary-model.ts b/webapp/frontend/src/app/core/models/device-summary-model.ts new file mode 100644 index 0000000..daf7ee4 --- /dev/null +++ b/webapp/frontend/src/app/core/models/device-summary-model.ts @@ -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 +} + diff --git a/webapp/frontend/src/app/core/models/device-summary-response-wrapper.ts b/webapp/frontend/src/app/core/models/device-summary-response-wrapper.ts new file mode 100644 index 0000000..cffac38 --- /dev/null +++ b/webapp/frontend/src/app/core/models/device-summary-response-wrapper.ts @@ -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 } + } +} diff --git a/webapp/frontend/src/app/core/models/device-summary-temp-response-wrapper.ts b/webapp/frontend/src/app/core/models/device-summary-temp-response-wrapper.ts new file mode 100644 index 0000000..c234fdd --- /dev/null +++ b/webapp/frontend/src/app/core/models/device-summary-temp-response-wrapper.ts @@ -0,0 +1,9 @@ +import {SmartTemperatureModel} from './measurements/smart-temperature-model'; + +export interface DeviceSummaryTempResponseWrapper { + success: boolean; + errors: any[]; + data: { + temp_history: { [key: string]: SmartTemperatureModel[]; } + } +} diff --git a/webapp/frontend/src/app/core/models/measurements/smart-attribute-model.ts b/webapp/frontend/src/app/core/models/measurements/smart-attribute-model.ts new file mode 100644 index 0000000..3a253b7 --- /dev/null +++ b/webapp/frontend/src/app/core/models/measurements/smart-attribute-model.ts @@ -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[] +} diff --git a/webapp/frontend/src/app/core/models/measurements/smart-model.ts b/webapp/frontend/src/app/core/models/measurements/smart-model.ts new file mode 100644 index 0000000..d44d2e1 --- /dev/null +++ b/webapp/frontend/src/app/core/models/measurements/smart-model.ts @@ -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 } +} diff --git a/webapp/frontend/src/app/core/models/measurements/smart-temperature-model.ts b/webapp/frontend/src/app/core/models/measurements/smart-temperature-model.ts new file mode 100644 index 0000000..3b05313 --- /dev/null +++ b/webapp/frontend/src/app/core/models/measurements/smart-temperature-model.ts @@ -0,0 +1,6 @@ +// maps to webapp/backend/pkg/models/measurements/smart_temperature.go +export interface SmartTemperatureModel { + date: string; + temp: number; +} + diff --git a/webapp/frontend/src/app/core/models/thresholds/attribute-metadata-model.ts b/webapp/frontend/src/app/core/models/thresholds/attribute-metadata-model.ts new file mode 100644 index 0000000..02a4e94 --- /dev/null +++ b/webapp/frontend/src/app/core/models/thresholds/attribute-metadata-model.ts @@ -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 +} diff --git a/webapp/frontend/src/app/data/mock/summary/temp_history.ts b/webapp/frontend/src/app/data/mock/summary/temp_history.ts new file mode 100644 index 0000000..80e6b45 --- /dev/null +++ b/webapp/frontend/src/app/data/mock/summary/temp_history.ts @@ -0,0 +1,1200 @@ +/* tslint:disable */ +export const temp_history = { + "data": { + "temp_history": { + "0x5000cca252c859cc": [{ + "date": "2022-07-01T22:00:00Z", + "temp": 36 + }, { + "date": "2022-07-01T23:00:00Z", + "temp": 36 + }, { + "date": "2022-07-02T00:00:00Z", + "temp": 36 + }, { + "date": "2022-07-02T01:00:00Z", + "temp": 36 + }, { + "date": "2022-07-02T22:00:00Z", + "temp": 36 + }, { + "date": "2022-07-02T23:00:00Z", + "temp": 36 + }, { + "date": "2022-07-03T00:00:00Z", + "temp": 36 + }, { + "date": "2022-07-03T01:00:00Z", + "temp": 36 + }, { + "date": "2022-07-03T22:00:00Z", + "temp": 42 + }, { + "date": "2022-07-03T23:00:00Z", + "temp": 42 + }, { + "date": "2022-07-04T00:00:00Z", + "temp": 42 + }, { + "date": "2022-07-04T01:00:00Z", + "temp": 42 + }, { + "date": "2022-07-04T22:00:00Z", + "temp": 37 + }, { + "date": "2022-07-04T23:00:00Z", + "temp": 36 + }, { + "date": "2022-07-05T00:00:00Z", + "temp": 36 + }, { + "date": "2022-07-05T01:00:00Z", + "temp": 37 + }, { + "date": "2022-07-05T22:00:00Z", + "temp": 37 + }, { + "date": "2022-07-05T23:00:00Z", + "temp": 37 + }, { + "date": "2022-07-06T00:00:00Z", + "temp": 37 + }, { + "date": "2022-07-06T01:00:00Z", + "temp": 37 + }, { + "date": "2022-07-06T04:00:00Z", + "temp": 37 + }, { + "date": "2022-07-06T05:00:00Z", + "temp": 37 + }, { + "date": "2022-07-06T06:00:00Z", + "temp": 37 + }, { + "date": "2022-07-06T22:00:00Z", + "temp": 38 + }, { + "date": "2022-07-06T23:00:00Z", + "temp": 38 + }, { + "date": "2022-07-07T00:00:00Z", + "temp": 38 + }, { + "date": "2022-07-07T01:00:00Z", + "temp": 38 + }, { + "date": "2022-07-07T03:00:00Z", + "temp": 37 + }, { + "date": "2022-07-07T04:00:00Z", + "temp": 37 + }, { + "date": "2022-07-07T05:00:00Z", + "temp": 37 + }, { + "date": "2022-07-07T06:00:00Z", + "temp": 37 + }, { + "date": "2022-07-07T07:00:00Z", + "temp": 38 + }, { + "date": "2022-07-07T22:00:00Z", + "temp": 37 + }, { + "date": "2022-07-07T23:00:00Z", + "temp": 37 + }, { + "date": "2022-07-08T00:00:00Z", + "temp": 37 + }, { + "date": "2022-07-08T01:00:00Z", + "temp": 37 + }, { + "date": "2022-07-08T13:00:00Z", + "temp": 35 + }, { + "date": "2022-07-08T14:00:00Z", + "temp": 35 + }, { + "date": "2022-07-08T15:00:00Z", + "temp": 35 + }], + "0x5000cca264eb01d7": [{ + "date": "2022-07-01T22:00:00Z", + "temp": 42 + }, { + "date": "2022-07-01T23:00:00Z", + "temp": 39 + }, { + "date": "2022-07-02T00:00:00Z", + "temp": 39 + }, { + "date": "2022-07-02T01:00:00Z", + "temp": 39 + }, { + "date": "2022-07-02T22:00:00Z", + "temp": 41 + }, { + "date": "2022-07-02T23:00:00Z", + "temp": 39 + }, { + "date": "2022-07-03T00:00:00Z", + "temp": 39 + }, { + "date": "2022-07-03T01:00:00Z", + "temp": 40 + }, { + "date": "2022-07-03T22:00:00Z", + "temp": 44 + }, { + "date": "2022-07-03T23:00:00Z", + "temp": 42 + }, { + "date": "2022-07-04T00:00:00Z", + "temp": 41 + }, { + "date": "2022-07-04T01:00:00Z", + "temp": 41 + }, { + "date": "2022-07-04T22:00:00Z", + "temp": 41 + }, { + "date": "2022-07-04T23:00:00Z", + "temp": 42 + }, { + "date": "2022-07-05T00:00:00Z", + "temp": 40 + }, { + "date": "2022-07-05T01:00:00Z", + "temp": 41 + }, { + "date": "2022-07-05T22:00:00Z", + "temp": 40 + }, { + "date": "2022-07-05T23:00:00Z", + "temp": 40 + }, { + "date": "2022-07-06T00:00:00Z", + "temp": 40 + }, { + "date": "2022-07-06T01:00:00Z", + "temp": 40 + }, { + "date": "2022-07-06T04:00:00Z", + "temp": 42 + }, { + "date": "2022-07-06T05:00:00Z", + "temp": 45 + }, { + "date": "2022-07-06T06:00:00Z", + "temp": 44 + }, { + "date": "2022-07-06T22:00:00Z", + "temp": 41 + }, { + "date": "2022-07-06T23:00:00Z", + "temp": 41 + }, { + "date": "2022-07-07T00:00:00Z", + "temp": 41 + }, { + "date": "2022-07-07T01:00:00Z", + "temp": 41 + }, { + "date": "2022-07-07T03:00:00Z", + "temp": 41 + }, { + "date": "2022-07-07T04:00:00Z", + "temp": 41 + }, { + "date": "2022-07-07T05:00:00Z", + "temp": 41 + }, { + "date": "2022-07-07T06:00:00Z", + "temp": 42 + }, { + "date": "2022-07-07T07:00:00Z", + "temp": 42 + }, { + "date": "2022-07-07T22:00:00Z", + "temp": 40 + }, { + "date": "2022-07-07T23:00:00Z", + "temp": 40 + }, { + "date": "2022-07-08T00:00:00Z", + "temp": 40 + }, { + "date": "2022-07-08T01:00:00Z", + "temp": 40 + }, { + "date": "2022-07-08T13:00:00Z", + "temp": 39 + }, { + "date": "2022-07-08T14:00:00Z", + "temp": 39 + }, { + "date": "2022-07-08T15:00:00Z", + "temp": 39 + }], + "0x5000cca264ebc248": [{ + "date": "2022-07-01T22:00:00Z", + "temp": 36 + }, { + "date": "2022-07-01T23:00:00Z", + "temp": 34 + }, { + "date": "2022-07-02T00:00:00Z", + "temp": 33 + }, { + "date": "2022-07-02T01:00:00Z", + "temp": 33 + }, { + "date": "2022-07-02T22:00:00Z", + "temp": 35 + }, { + "date": "2022-07-02T23:00:00Z", + "temp": 34 + }, { + "date": "2022-07-03T00:00:00Z", + "temp": 33 + }, { + "date": "2022-07-03T01:00:00Z", + "temp": 33 + }, { + "date": "2022-07-03T22:00:00Z", + "temp": 37 + }, { + "date": "2022-07-03T23:00:00Z", + "temp": 37 + }, { + "date": "2022-07-04T00:00:00Z", + "temp": 33 + }, { + "date": "2022-07-04T01:00:00Z", + "temp": 33 + }, { + "date": "2022-07-04T22:00:00Z", + "temp": 35 + }, { + "date": "2022-07-04T23:00:00Z", + "temp": 35 + }, { + "date": "2022-07-05T00:00:00Z", + "temp": 35 + }, { + "date": "2022-07-05T01:00:00Z", + "temp": 35 + }, { + "date": "2022-07-05T22:00:00Z", + "temp": 36 + }, { + "date": "2022-07-05T23:00:00Z", + "temp": 35 + }, { + "date": "2022-07-06T00:00:00Z", + "temp": 35 + }, { + "date": "2022-07-06T01:00:00Z", + "temp": 36 + }, { + "date": "2022-07-06T04:00:00Z", + "temp": 38 + }, { + "date": "2022-07-06T05:00:00Z", + "temp": 39 + }, { + "date": "2022-07-06T06:00:00Z", + "temp": 38 + }, { + "date": "2022-07-06T22:00:00Z", + "temp": 35 + }, { + "date": "2022-07-06T23:00:00Z", + "temp": 35 + }, { + "date": "2022-07-07T00:00:00Z", + "temp": 35 + }, { + "date": "2022-07-07T01:00:00Z", + "temp": 35 + }, { + "date": "2022-07-07T03:00:00Z", + "temp": 38 + }, { + "date": "2022-07-07T04:00:00Z", + "temp": 36 + }, { + "date": "2022-07-07T05:00:00Z", + "temp": 35 + }, { + "date": "2022-07-07T06:00:00Z", + "temp": 37 + }, { + "date": "2022-07-07T07:00:00Z", + "temp": 36 + }, { + "date": "2022-07-07T22:00:00Z", + "temp": 34 + }, { + "date": "2022-07-07T23:00:00Z", + "temp": 34 + }, { + "date": "2022-07-08T00:00:00Z", + "temp": 34 + }, { + "date": "2022-07-08T01:00:00Z", + "temp": 34 + }, { + "date": "2022-07-08T13:00:00Z", + "temp": 33 + }, { + "date": "2022-07-08T14:00:00Z", + "temp": 33 + }, { + "date": "2022-07-08T15:00:00Z", + "temp": 33 + }], + "0x5000cca264ec3183": [{ + "date": "2022-07-01T22:00:00Z", + "temp": 39 + }, { + "date": "2022-07-01T23:00:00Z", + "temp": 38 + }, { + "date": "2022-07-02T00:00:00Z", + "temp": 37 + }, { + "date": "2022-07-02T01:00:00Z", + "temp": 37 + }, { + "date": "2022-07-02T22:00:00Z", + "temp": 39 + }, { + "date": "2022-07-02T23:00:00Z", + "temp": 37 + }, { + "date": "2022-07-03T00:00:00Z", + "temp": 39 + }, { + "date": "2022-07-03T01:00:00Z", + "temp": 40 + }, { + "date": "2022-07-03T22:00:00Z", + "temp": 40 + }, { + "date": "2022-07-03T23:00:00Z", + "temp": 39 + }, { + "date": "2022-07-04T00:00:00Z", + "temp": 38 + }, { + "date": "2022-07-04T01:00:00Z", + "temp": 38 + }, { + "date": "2022-07-04T22:00:00Z", + "temp": 38 + }, { + "date": "2022-07-04T23:00:00Z", + "temp": 39 + }, { + "date": "2022-07-05T00:00:00Z", + "temp": 38 + }, { + "date": "2022-07-05T01:00:00Z", + "temp": 38 + }, { + "date": "2022-07-05T22:00:00Z", + "temp": 38 + }, { + "date": "2022-07-05T23:00:00Z", + "temp": 38 + }, { + "date": "2022-07-06T00:00:00Z", + "temp": 38 + }, { + "date": "2022-07-06T01:00:00Z", + "temp": 38 + }, { + "date": "2022-07-06T04:00:00Z", + "temp": 41 + }, { + "date": "2022-07-06T05:00:00Z", + "temp": 42 + }, { + "date": "2022-07-06T06:00:00Z", + "temp": 40 + }, { + "date": "2022-07-06T22:00:00Z", + "temp": 39 + }, { + "date": "2022-07-06T23:00:00Z", + "temp": 39 + }, { + "date": "2022-07-07T00:00:00Z", + "temp": 39 + }, { + "date": "2022-07-07T01:00:00Z", + "temp": 39 + }, { + "date": "2022-07-07T03:00:00Z", + "temp": 40 + }, { + "date": "2022-07-07T04:00:00Z", + "temp": 40 + }, { + "date": "2022-07-07T05:00:00Z", + "temp": 39 + }, { + "date": "2022-07-07T06:00:00Z", + "temp": 40 + }, { + "date": "2022-07-07T07:00:00Z", + "temp": 41 + }, { + "date": "2022-07-07T22:00:00Z", + "temp": 38 + }, { + "date": "2022-07-07T23:00:00Z", + "temp": 38 + }, { + "date": "2022-07-08T00:00:00Z", + "temp": 38 + }, { + "date": "2022-07-08T01:00:00Z", + "temp": 38 + }, { + "date": "2022-07-08T13:00:00Z", + "temp": 37 + }, { + "date": "2022-07-08T14:00:00Z", + "temp": 37 + }, { + "date": "2022-07-08T15:00:00Z", + "temp": 37 + }], + "0x5000cca28ed7fcd8": [{ + "date": "2022-07-01T22:00:00Z", + "temp": 36 + }, { + "date": "2022-07-01T23:00:00Z", + "temp": 34 + }, { + "date": "2022-07-02T00:00:00Z", + "temp": 34 + }, { + "date": "2022-07-02T01:00:00Z", + "temp": 34 + }, { + "date": "2022-07-02T22:00:00Z", + "temp": 35 + }, { + "date": "2022-07-02T23:00:00Z", + "temp": 34 + }, { + "date": "2022-07-03T00:00:00Z", + "temp": 33 + }, { + "date": "2022-07-03T01:00:00Z", + "temp": 33 + }, { + "date": "2022-07-03T22:00:00Z", + "temp": 36 + }, { + "date": "2022-07-03T23:00:00Z", + "temp": 36 + }, { + "date": "2022-07-04T00:00:00Z", + "temp": 33 + }, { + "date": "2022-07-04T01:00:00Z", + "temp": 33 + }, { + "date": "2022-07-04T22:00:00Z", + "temp": 39 + }, { + "date": "2022-07-04T23:00:00Z", + "temp": 39 + }, { + "date": "2022-07-05T00:00:00Z", + "temp": 39 + }, { + "date": "2022-07-05T01:00:00Z", + "temp": 39 + }, { + "date": "2022-07-05T22:00:00Z", + "temp": 40 + }, { + "date": "2022-07-05T23:00:00Z", + "temp": 40 + }, { + "date": "2022-07-06T00:00:00Z", + "temp": 40 + }, { + "date": "2022-07-06T01:00:00Z", + "temp": 40 + }, { + "date": "2022-07-06T04:00:00Z", + "temp": 38 + }, { + "date": "2022-07-06T05:00:00Z", + "temp": 38 + }, { + "date": "2022-07-06T06:00:00Z", + "temp": 36 + }, { + "date": "2022-07-06T22:00:00Z", + "temp": 36 + }, { + "date": "2022-07-06T23:00:00Z", + "temp": 35 + }, { + "date": "2022-07-07T00:00:00Z", + "temp": 36 + }, { + "date": "2022-07-07T01:00:00Z", + "temp": 36 + }, { + "date": "2022-07-07T03:00:00Z", + "temp": 38 + }, { + "date": "2022-07-07T04:00:00Z", + "temp": 37 + }, { + "date": "2022-07-07T05:00:00Z", + "temp": 36 + }, { + "date": "2022-07-07T06:00:00Z", + "temp": 39 + }, { + "date": "2022-07-07T07:00:00Z", + "temp": 37 + }, { + "date": "2022-07-07T22:00:00Z", + "temp": 34 + }, { + "date": "2022-07-07T23:00:00Z", + "temp": 35 + }, { + "date": "2022-07-08T00:00:00Z", + "temp": 34 + }, { + "date": "2022-07-08T01:00:00Z", + "temp": 34 + }, { + "date": "2022-07-08T13:00:00Z", + "temp": 33 + }, { + "date": "2022-07-08T14:00:00Z", + "temp": 34 + }, { + "date": "2022-07-08T15:00:00Z", + "temp": 34 + }], + "0x5000cca28fc25581": [{ + "date": "2022-07-01T22:00:00Z", + "temp": 39 + }, { + "date": "2022-07-01T23:00:00Z", + "temp": 38 + }, { + "date": "2022-07-02T00:00:00Z", + "temp": 38 + }, { + "date": "2022-07-02T01:00:00Z", + "temp": 38 + }, { + "date": "2022-07-02T22:00:00Z", + "temp": 39 + }, { + "date": "2022-07-02T23:00:00Z", + "temp": 39 + }, { + "date": "2022-07-03T00:00:00Z", + "temp": 39 + }, { + "date": "2022-07-03T01:00:00Z", + "temp": 39 + }, { + "date": "2022-07-03T22:00:00Z", + "temp": 46 + }, { + "date": "2022-07-03T23:00:00Z", + "temp": 46 + }, { + "date": "2022-07-04T00:00:00Z", + "temp": 46 + }, { + "date": "2022-07-04T01:00:00Z", + "temp": 46 + }, { + "date": "2022-07-04T22:00:00Z", + "temp": 40 + }, { + "date": "2022-07-04T23:00:00Z", + "temp": 39 + }, { + "date": "2022-07-05T00:00:00Z", + "temp": 39 + }, { + "date": "2022-07-05T01:00:00Z", + "temp": 41 + }, { + "date": "2022-07-05T22:00:00Z", + "temp": 40 + }, { + "date": "2022-07-05T23:00:00Z", + "temp": 40 + }, { + "date": "2022-07-06T00:00:00Z", + "temp": 40 + }, { + "date": "2022-07-06T01:00:00Z", + "temp": 40 + }, { + "date": "2022-07-06T04:00:00Z", + "temp": 41 + }, { + "date": "2022-07-06T05:00:00Z", + "temp": 41 + }, { + "date": "2022-07-06T06:00:00Z", + "temp": 41 + }, { + "date": "2022-07-06T22:00:00Z", + "temp": 40 + }, { + "date": "2022-07-06T23:00:00Z", + "temp": 40 + }, { + "date": "2022-07-07T00:00:00Z", + "temp": 41 + }, { + "date": "2022-07-07T01:00:00Z", + "temp": 41 + }, { + "date": "2022-07-07T03:00:00Z", + "temp": 41 + }, { + "date": "2022-07-07T04:00:00Z", + "temp": 40 + }, { + "date": "2022-07-07T05:00:00Z", + "temp": 40 + }, { + "date": "2022-07-07T06:00:00Z", + "temp": 41 + }, { + "date": "2022-07-07T07:00:00Z", + "temp": 40 + }, { + "date": "2022-07-07T22:00:00Z", + "temp": 39 + }, { + "date": "2022-07-07T23:00:00Z", + "temp": 39 + }, { + "date": "2022-07-08T00:00:00Z", + "temp": 39 + }, { + "date": "2022-07-08T01:00:00Z", + "temp": 39 + }, { + "date": "2022-07-08T13:00:00Z", + "temp": 38 + }, { + "date": "2022-07-08T14:00:00Z", + "temp": 38 + }, { + "date": "2022-07-08T15:00:00Z", + "temp": 38 + }], + "0x5002538e40a22954": [{ + "date": "2022-07-01T19:00:00Z", + "temp": 30 + }, { + "date": "2022-07-01T20:00:00Z", + "temp": 31 + }, { + "date": "2022-07-01T21:00:00Z", + "temp": 31 + }, { + "date": "2022-07-01T22:00:00Z", + "temp": 31 + }, { + "date": "2022-07-01T23:00:00Z", + "temp": 30 + }, { + "date": "2022-07-02T00:00:00Z", + "temp": 30 + }, { + "date": "2022-07-02T01:00:00Z", + "temp": 31 + }, { + "date": "2022-07-02T03:00:00Z", + "temp": 31 + }, { + "date": "2022-07-02T04:00:00Z", + "temp": 30 + }, { + "date": "2022-07-02T05:00:00Z", + "temp": 30 + }, { + "date": "2022-07-02T06:00:00Z", + "temp": 30 + }, { + "date": "2022-07-02T07:00:00Z", + "temp": 29 + }, { + "date": "2022-07-02T08:00:00Z", + "temp": 29 + }, { + "date": "2022-07-02T09:00:00Z", + "temp": 29 + }, { + "date": "2022-07-02T10:00:00Z", + "temp": 30 + }, { + "date": "2022-07-02T11:00:00Z", + "temp": 30 + }, { + "date": "2022-07-02T12:00:00Z", + "temp": 29 + }, { + "date": "2022-07-02T13:00:00Z", + "temp": 28 + }, { + "date": "2022-07-02T14:00:00Z", + "temp": 28 + }, { + "date": "2022-07-02T15:00:00Z", + "temp": 28 + }, { + "date": "2022-07-02T16:00:00Z", + "temp": 29 + }, { + "date": "2022-07-02T17:00:00Z", + "temp": 29 + }, { + "date": "2022-07-02T18:00:00Z", + "temp": 29 + }, { + "date": "2022-07-02T19:00:00Z", + "temp": 29 + }, { + "date": "2022-07-02T20:00:00Z", + "temp": 29 + }, { + "date": "2022-07-02T21:00:00Z", + "temp": 29 + }, { + "date": "2022-07-02T22:00:00Z", + "temp": 29 + }, { + "date": "2022-07-02T23:00:00Z", + "temp": 29 + }, { + "date": "2022-07-03T00:00:00Z", + "temp": 29 + }, { + "date": "2022-07-03T01:00:00Z", + "temp": 29 + }, { + "date": "2022-07-03T03:00:00Z", + "temp": 32 + }, { + "date": "2022-07-03T04:00:00Z", + "temp": 31 + }, { + "date": "2022-07-03T05:00:00Z", + "temp": 30 + }, { + "date": "2022-07-03T06:00:00Z", + "temp": 29 + }, { + "date": "2022-07-03T07:00:00Z", + "temp": 29 + }, { + "date": "2022-07-03T08:00:00Z", + "temp": 29 + }, { + "date": "2022-07-03T09:00:00Z", + "temp": 30 + }, { + "date": "2022-07-03T10:00:00Z", + "temp": 29 + }, { + "date": "2022-07-03T11:00:00Z", + "temp": 31 + }, { + "date": "2022-07-03T12:00:00Z", + "temp": 30 + }, { + "date": "2022-07-03T13:00:00Z", + "temp": 29 + }, { + "date": "2022-07-03T14:00:00Z", + "temp": 29 + }, { + "date": "2022-07-03T15:00:00Z", + "temp": 30 + }, { + "date": "2022-07-03T16:00:00Z", + "temp": 29 + }, { + "date": "2022-07-03T17:00:00Z", + "temp": 29 + }, { + "date": "2022-07-03T18:00:00Z", + "temp": 30 + }, { + "date": "2022-07-03T19:00:00Z", + "temp": 30 + }, { + "date": "2022-07-03T20:00:00Z", + "temp": 30 + }, { + "date": "2022-07-03T21:00:00Z", + "temp": 31 + }, { + "date": "2022-07-03T22:00:00Z", + "temp": 32 + }, { + "date": "2022-07-03T23:00:00Z", + "temp": 31 + }, { + "date": "2022-07-04T00:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T01:00:00Z", + "temp": 31 + }, { + "date": "2022-07-04T03:00:00Z", + "temp": 31 + }, { + "date": "2022-07-04T04:00:00Z", + "temp": 32 + }, { + "date": "2022-07-04T05:00:00Z", + "temp": 31 + }, { + "date": "2022-07-04T06:00:00Z", + "temp": 31 + }, { + "date": "2022-07-04T07:00:00Z", + "temp": 31 + }, { + "date": "2022-07-04T08:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T09:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T10:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T11:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T12:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T13:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T14:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T15:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T16:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T17:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T18:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T19:00:00Z", + "temp": 30 + }, { + "date": "2022-07-04T20:00:00Z", + "temp": 31 + }, { + "date": "2022-07-04T21:00:00Z", + "temp": 31 + }, { + "date": "2022-07-04T22:00:00Z", + "temp": 31 + }, { + "date": "2022-07-04T23:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T00:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T01:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T03:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T04:00:00Z", + "temp": 31 + }, { + "date": "2022-07-05T05:00:00Z", + "temp": 31 + }, { + "date": "2022-07-05T06:00:00Z", + "temp": 31 + }, { + "date": "2022-07-05T07:00:00Z", + "temp": 31 + }, { + "date": "2022-07-05T08:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T09:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T10:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T11:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T12:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T13:00:00Z", + "temp": 31 + }, { + "date": "2022-07-05T14:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T15:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T16:00:00Z", + "temp": 31 + }, { + "date": "2022-07-05T17:00:00Z", + "temp": 31 + }, { + "date": "2022-07-05T18:00:00Z", + "temp": 31 + }, { + "date": "2022-07-05T19:00:00Z", + "temp": 31 + }, { + "date": "2022-07-05T20:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T21:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T22:00:00Z", + "temp": 32 + }, { + "date": "2022-07-05T23:00:00Z", + "temp": 32 + }, { + "date": "2022-07-06T00:00:00Z", + "temp": 32 + }, { + "date": "2022-07-06T01:00:00Z", + "temp": 31 + }, { + "date": "2022-07-06T02:00:00Z", + "temp": 31 + }, { + "date": "2022-07-06T03:00:00Z", + "temp": 31 + }, { + "date": "2022-07-06T04:00:00Z", + "temp": 31 + }, { + "date": "2022-07-06T05:00:00Z", + "temp": 31 + }, { + "date": "2022-07-06T06:00:00Z", + "temp": 31 + }, { + "date": "2022-07-06T07:00:00Z", + "temp": 37 + }, { + "date": "2022-07-06T08:00:00Z", + "temp": 36 + }, { + "date": "2022-07-06T09:00:00Z", + "temp": 32 + }, { + "date": "2022-07-06T10:00:00Z", + "temp": 31 + }, { + "date": "2022-07-06T11:00:00Z", + "temp": 32 + }, { + "date": "2022-07-06T12:00:00Z", + "temp": 32 + }, { + "date": "2022-07-06T13:00:00Z", + "temp": 32 + }, { + "date": "2022-07-06T14:00:00Z", + "temp": 32 + }, { + "date": "2022-07-06T15:00:00Z", + "temp": 32 + }, { + "date": "2022-07-06T16:00:00Z", + "temp": 31 + }, { + "date": "2022-07-06T17:00:00Z", + "temp": 33 + }, { + "date": "2022-07-06T18:00:00Z", + "temp": 33 + }, { + "date": "2022-07-06T19:00:00Z", + "temp": 32 + }, { + "date": "2022-07-06T20:00:00Z", + "temp": 34 + }, { + "date": "2022-07-06T21:00:00Z", + "temp": 32 + }, { + "date": "2022-07-06T22:00:00Z", + "temp": 31 + }, { + "date": "2022-07-06T23:00:00Z", + "temp": 31 + }, { + "date": "2022-07-07T00:00:00Z", + "temp": 31 + }, { + "date": "2022-07-07T01:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T02:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T03:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T04:00:00Z", + "temp": 31 + }, { + "date": "2022-07-07T05:00:00Z", + "temp": 31 + }, { + "date": "2022-07-07T06:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T07:00:00Z", + "temp": 31 + }, { + "date": "2022-07-07T08:00:00Z", + "temp": 31 + }, { + "date": "2022-07-07T09:00:00Z", + "temp": 31 + }, { + "date": "2022-07-07T10:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T11:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T12:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T13:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T14:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T15:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T16:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T17:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T18:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T19:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T20:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T21:00:00Z", + "temp": 30 + }, { + "date": "2022-07-07T22:00:00Z", + "temp": 31 + }, { + "date": "2022-07-07T23:00:00Z", + "temp": 31 + }, { + "date": "2022-07-08T00:00:00Z", + "temp": 31 + }, { + "date": "2022-07-08T01:00:00Z", + "temp": 31 + }, { + "date": "2022-07-08T02:00:00Z", + "temp": 30 + }, { + "date": "2022-07-08T03:00:00Z", + "temp": 31 + }, { + "date": "2022-07-08T04:00:00Z", + "temp": 31 + }, { + "date": "2022-07-08T05:00:00Z", + "temp": 32 + }, { + "date": "2022-07-08T06:00:00Z", + "temp": 34 + }, { + "date": "2022-07-08T07:00:00Z", + "temp": 34 + }, { + "date": "2022-07-08T08:00:00Z", + "temp": 34 + }, { + "date": "2022-07-08T09:00:00Z", + "temp": 31 + }, { + "date": "2022-07-08T10:00:00Z", + "temp": 30 + }, { + "date": "2022-07-08T11:00:00Z", + "temp": 30 + }, { + "date": "2022-07-08T12:00:00Z", + "temp": 30 + }, { + "date": "2022-07-08T13:00:00Z", + "temp": 31 + }, { + "date": "2022-07-08T14:00:00Z", + "temp": 31 + }, { + "date": "2022-07-08T15:00:00Z", + "temp": 31 + }] + } + }, + "success": true +} diff --git a/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component.spec.ts b/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component.spec.ts index db01c53..26248f1 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component.spec.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component.spec.ts @@ -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', () => { - let component: DashboardDeviceDeleteDialogComponent; - let fixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ DashboardDeviceDeleteDialogComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(DashboardDeviceDeleteDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); + let component: DashboardDeviceDeleteDialogComponent; + let fixture: ComponentFixture; + + const matDialogRefSpy = jasmine.createSpyObj('MatDialogRef', ['closeDialog', 'close']); + const dashboardDeviceDeleteDialogServiceSpy = jasmine.createSpyObj('DashboardDeviceDeleteDialogService', ['deleteDevice']); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + 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() + })); + + beforeEach(() => { + 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); + }); }); diff --git a/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component.ts b/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component.ts index d995887..5fdd8a0 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component.ts @@ -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 {DashboardDeviceDeleteDialogService} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.service'; -import {Subject} from 'rxjs'; @Component({ selector: 'app-dashboard-device-delete-dialog', diff --git a/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.module.ts b/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.module.ts index 2605777..5d3799f 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.module.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.module.ts @@ -1,44 +1,21 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { Overlay } from '@angular/cdk/overlay'; -import { MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatButtonModule } from '@angular/material/button'; -import { MatSelectModule } from '@angular/material/select'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { SharedModule } from 'app/shared/shared.module'; +import {NgModule} from '@angular/core'; +import {RouterModule} from '@angular/router'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {SharedModule} from 'app/shared/shared.module'; 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 {MatDividerModule} from '@angular/material/divider'; -import {MatMenuModule} from '@angular/material/menu'; -import {MatProgressBarModule} from '@angular/material/progress-bar'; -import {MatSortModule} from '@angular/material/sort'; -import {MatTableModule} from '@angular/material/table'; -import {NgApexchartsModule} from 'ng-apexcharts'; -import { MatDialogModule } from '@angular/material/dialog'; +import {MatDialogModule} from '@angular/material/dialog'; @NgModule({ declarations: [ DashboardDeviceDeleteDialogComponent ], - imports : [ + imports: [ RouterModule.forChild([]), RouterModule.forChild(dashboardRoutes), MatButtonModule, - MatDividerModule, - MatTooltipModule, MatIconModule, - MatMenuModule, - MatProgressBarModule, - MatSortModule, - MatTableModule, - NgApexchartsModule, SharedModule, MatDialogModule ], diff --git a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.spec.ts b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.spec.ts index dba412c..7b334bb 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.spec.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.spec.ts @@ -1,25 +1,105 @@ -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'; describe('DashboardDeviceComponent', () => { - let component: DashboardDeviceComponent; - let fixture: ComponentFixture; + let component: DashboardDeviceComponent; + let fixture: ComponentFixture; - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ DashboardDeviceComponent ] + const matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']); + // const configServiceSpy = jasmine.createSpyObj('TreoConfigService', ['config$']); + + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + MatButtonModule, + MatIconModule, + MatMenuModule, + SharedModule, + ], + providers: [ + {provide: MatDialog, useValue: matDialogSpy}, + {provide: TREO_APP_CONFIG, useValue: {dashboardDisplay: 'name'}} + ], + 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', () => { + // component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel + expect(component.classDeviceLastUpdatedOn({ + device: { + device_status: 2 + } + } as DeviceSummaryModel)).toBe('text-red') + }); + + it('if non-zero device status, should be red', () => { + // component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel + expect(component.classDeviceLastUpdatedOn({ + device: { + device_status: 2 + } + } as DeviceSummaryModel)).toBe('text-red') + }); + + it('if healthy device status and updated in the last two weeks, should be green', () => { + // component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel + 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', () => { + // component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel + 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', () => { + // component.deviceSummary = summary.data.summary['0x5000c500673e6b5f'] as DeviceSummaryModel + 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(); - }); + }); diff --git a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.ts b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.ts index d2c9859..4fb7d7a 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.ts @@ -1,18 +1,19 @@ -import { Component, Input, Output, OnInit, EventEmitter} from '@angular/core'; +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; import * as moment from 'moment'; import {takeUntil} from 'rxjs/operators'; import {AppConfig} from 'app/core/config/app.config'; import {TreoConfigService} from '@treo/services/config'; import {Subject} from 'rxjs'; -import humanizeDuration from 'humanize-duration' +import humanizeDuration from 'humanize-duration' import {MatDialog} from '@angular/material/dialog'; import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component'; import {DeviceTitlePipe} from 'app/shared/device-title.pipe'; +import {DeviceSummaryModel} from 'app/core/models/device-summary-model'; @Component({ - selector: 'app-dashboard-device', - templateUrl: './dashboard-device.component.html', - styleUrls: ['./dashboard-device.component.scss'] + selector: 'app-dashboard-device', + templateUrl: './dashboard-device.component.html', + styleUrls: ['./dashboard-device.component.scss'] }) export class DashboardDeviceComponent implements OnInit { @@ -23,7 +24,8 @@ export class DashboardDeviceComponent implements OnInit { // Set the private defaults this._unsubscribeAll = new Subject(); } - @Input() deviceSummary: any; + + @Input() deviceSummary: DeviceSummaryModel; @Input() deviceWWN: string; @Output() deviceDeleted = new EventEmitter(); @@ -47,28 +49,27 @@ export class DashboardDeviceComponent implements OnInit { // @ Public methods // ----------------------------------------------------------------------------------------------------- - classDeviceLastUpdatedOn(deviceSummary): string { + classDeviceLastUpdatedOn(deviceSummary: DeviceSummaryModel): string { if (deviceSummary.device.device_status !== 0) { return 'text-red' // if the device has failed, always highlight in red - } else if(deviceSummary.device.device_status === 0 && deviceSummary.smart){ - if(moment().subtract(14, 'd').isBefore(deviceSummary.smart.collector_date)){ + } else if (deviceSummary.device.device_status === 0 && deviceSummary.smart) { + if (moment().subtract(14, 'days').isBefore(deviceSummary.smart.collector_date)) { // this device was updated in the last 2 weeks. return 'text-green' - } else if(moment().subtract(1, 'm').isBefore(deviceSummary.smart.collector_date)){ + } else if (moment().subtract(1, 'months').isBefore(deviceSummary.smart.collector_date)) { // this device was updated in the last month return 'text-yellow' - } else{ + } else { // last updated more than a month ago. return 'text-red' } - } else { return '' } } - deviceStatusString(deviceStatus): string { - if(deviceStatus === 0){ + deviceStatusString(deviceStatus: number): string { + if (deviceStatus === 0) { return 'passed' } else { return 'failed' @@ -76,16 +77,18 @@ export class DashboardDeviceComponent implements OnInit { } - openDeleteDialog(): void { const dialogRef = this.dialog.open(DashboardDeviceDeleteDialogComponent, { // 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.dashboardDisplay) + } }); dialogRef.afterClosed().subscribe(result => { console.log('The dialog was closed', result); - if(result.success){ + if (result.success) { this.deviceDeleted.emit(this.deviceWWN) } }); diff --git a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.module.ts b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.module.ts index e338330..924c145 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.module.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.module.ts @@ -1,53 +1,30 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { Overlay } from '@angular/cdk/overlay'; -import { MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatButtonModule } from '@angular/material/button'; -import { MatSelectModule } from '@angular/material/select'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { SharedModule } from 'app/shared/shared.module'; +import {NgModule} from '@angular/core'; +import {RouterModule} from '@angular/router'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {SharedModule} from 'app/shared/shared.module'; import {DashboardDeviceComponent} from 'app/layout/common/dashboard-device/dashboard-device.component' -import { MatDialogModule } from '@angular/material/dialog'; -import { MatButtonToggleModule} from '@angular/material/button-toggle'; -import {MatTabsModule} from '@angular/material/tabs'; -import {MatSliderModule} from '@angular/material/slider'; -import {MatSlideToggleModule} from '@angular/material/slide-toggle'; -import {MatTooltipModule} from '@angular/material/tooltip'; import {dashboardRoutes} from '../../../modules/dashboard/dashboard.routing'; -import {MatDividerModule} from '@angular/material/divider'; import {MatMenuModule} from '@angular/material/menu'; -import {MatProgressBarModule} from '@angular/material/progress-bar'; -import {MatSortModule} from '@angular/material/sort'; -import {MatTableModule} from '@angular/material/table'; -import {NgApexchartsModule} from 'ng-apexcharts'; import {DashboardDeviceDeleteDialogModule} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.module'; @NgModule({ declarations: [ DashboardDeviceComponent ], - imports : [ + imports: [ RouterModule.forChild([]), RouterModule.forChild(dashboardRoutes), MatButtonModule, - MatDividerModule, - MatTooltipModule, MatIconModule, MatMenuModule, - MatProgressBarModule, - MatSortModule, - MatTableModule, - NgApexchartsModule, SharedModule, DashboardDeviceDeleteDialogModule ], - exports : [ + exports: [ DashboardDeviceComponent, ], - providers : [] + providers: [] }) -export class DashboardDeviceModule -{ +export class DashboardDeviceModule { } diff --git a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.spec.ts b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.spec.ts deleted file mode 100644 index 95a052f..0000000 --- a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.spec.ts +++ /dev/null @@ -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; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ DashboardSettingsComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(DashboardSettingsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts index 893aadf..70a0978 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts @@ -1,13 +1,13 @@ -import { Component, OnInit } from '@angular/core'; +import {Component, OnInit} from '@angular/core'; import {AppConfig} from 'app/core/config/app.config'; -import { TreoConfigService } from '@treo/services/config'; +import {TreoConfigService} from '@treo/services/config'; import {Subject} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; @Component({ - selector: 'app-dashboard-settings', - templateUrl: './dashboard-settings.component.html', - styleUrls: ['./dashboard-settings.component.scss'] + selector: 'app-dashboard-settings', + templateUrl: './dashboard-settings.component.html', + styleUrls: ['./dashboard-settings.component.scss'] }) export class DashboardSettingsComponent implements OnInit { @@ -26,25 +26,23 @@ export class DashboardSettingsComponent implements OnInit { this._unsubscribeAll = new Subject(); } - ngOnInit(): void { - // Subscribe to config changes - this._configService.config$ - .pipe(takeUntil(this._unsubscribeAll)) - .subscribe((config: AppConfig) => { + ngOnInit(): void { + // Subscribe to config changes + this._configService.config$ + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe((config: AppConfig) => { - // Store the config - this.dashboardDisplay = config.dashboardDisplay; - this.dashboardSort = config.dashboardSort; - this.temperatureUnit = config.temperatureUnit; - this.theme = config.theme; + // Store the config + this.dashboardDisplay = config.dashboardDisplay; + this.dashboardSort = config.dashboardSort; + this.temperatureUnit = config.temperatureUnit; + this.theme = config.theme; - }); - - } - - saveSettings(): void { + }); + } + saveSettings(): void { const newSettings = { dashboardDisplay: this.dashboardDisplay, dashboardSort: this.dashboardSort, @@ -53,7 +51,7 @@ export class DashboardSettingsComponent implements OnInit { } this._configService.config = newSettings console.log(`Saved Settings: ${JSON.stringify(newSettings)}`) - } + } formatLabel(value: number): number { return value; diff --git a/webapp/frontend/src/app/layout/common/detail-settings/detail-settings.module.ts b/webapp/frontend/src/app/layout/common/detail-settings/detail-settings.module.ts index b8c05df..a85e0ca 100644 --- a/webapp/frontend/src/app/layout/common/detail-settings/detail-settings.module.ts +++ b/webapp/frontend/src/app/layout/common/detail-settings/detail-settings.module.ts @@ -1,16 +1,15 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { Overlay } from '@angular/cdk/overlay'; -import { MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatButtonModule } from '@angular/material/button'; -import { MatSelectModule } from '@angular/material/select'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { SharedModule } from 'app/shared/shared.module'; +import {NgModule} from '@angular/core'; +import {RouterModule} from '@angular/router'; +import {MatAutocompleteModule} from '@angular/material/autocomplete'; +import {MatButtonModule} from '@angular/material/button'; +import {MatSelectModule} from '@angular/material/select'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatIconModule} from '@angular/material/icon'; +import {MatInputModule} from '@angular/material/input'; +import {SharedModule} from 'app/shared/shared.module'; import {DetailSettingsComponent} from 'app/layout/common/detail-settings/detail-settings.component' -import { MatDialogModule } from '@angular/material/dialog'; -import { MatButtonToggleModule} from '@angular/material/button-toggle'; +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'; @@ -20,7 +19,7 @@ import {MatTooltipModule} from '@angular/material/tooltip'; declarations: [ DetailSettingsComponent ], - imports : [ + imports: [ RouterModule.forChild([]), MatAutocompleteModule, MatDialogModule, @@ -36,11 +35,10 @@ import {MatTooltipModule} from '@angular/material/tooltip'; MatSlideToggleModule, SharedModule ], - exports : [ + exports: [ DetailSettingsComponent ], - providers : [] + providers: [] }) -export class DetailSettingsModule -{ +export class DetailSettingsModule { } diff --git a/webapp/frontend/src/app/modules/dashboard/dashboard.component.html b/webapp/frontend/src/app/modules/dashboard/dashboard.component.html index cbb19a4..d370ab5 100644 --- a/webapp/frontend/src/app/modules/dashboard/dashboard.component.html +++ b/webapp/frontend/src/app/modules/dashboard/dashboard.component.html @@ -1,5 +1,5 @@ -
+
diff --git a/webapp/frontend/src/app/modules/dashboard/dashboard.component.ts b/webapp/frontend/src/app/modules/dashboard/dashboard.component.ts index eb5a2cc..7352e98 100644 --- a/webapp/frontend/src/app/modules/dashboard/dashboard.component.ts +++ b/webapp/frontend/src/app/modules/dashboard/dashboard.component.ts @@ -1,17 +1,24 @@ -import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; -import { MatSort } from '@angular/material/sort'; -import { MatTableDataSource } from '@angular/material/table'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import {Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; 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 { 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 {TreoConfigService} from '@treo/services/config'; import {Router} from '@angular/router'; import {TemperaturePipe} from 'app/shared/temperature.pipe'; import {DeviceTitlePipe} from 'app/shared/device-title.pipe'; +import {DeviceSummaryModel} from 'app/core/models/device-summary-model'; @Component({ selector : 'example', @@ -22,7 +29,7 @@ import {DeviceTitlePipe} from 'app/shared/device-title.pipe'; }) export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy { - data: any; + summaryData: { [key: string]: DeviceSummaryModel }; hostGroups: { [hostId: string]: string[] } = {} temperatureOptions: ApexOptions; tempDurationKey = 'forever' @@ -35,10 +42,13 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy /** * Constructor * - * @param {SmartService} _smartService + * @param {DashboardService} _dashboardService + * @param {TreoConfigService} _configService + * @param {MatDialog} dialog + * @param {Router} router */ constructor( - private _smartService: DashboardService, + private _dashboardService: DashboardService, private _configService: TreoConfigService, public dialog: MatDialog, private router: Router, @@ -81,16 +91,16 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy }); // Get the data - this._smartService.data$ + this._dashboardService.data$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe((data) => { // Store the data - this.data = data; + this.summaryData = data; // generate group data. - for(const wwn in this.data.data.summary){ - const hostid = this.data.data.summary[wwn].device.host_id + for (const wwn in this.summaryData) { + const hostid = this.summaryData[wwn].device.host_id const hostDeviceList = this.hostGroups[hostid] || [] hostDeviceList.push(wwn) this.hostGroups[hostid] = hostDeviceList @@ -132,11 +142,11 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy private _deviceDataTemperatureSeries(): any[] { const deviceTemperatureSeries = [] - console.log('DEVICE DATA SUMMARY', this.data) + console.log('DEVICE DATA SUMMARY', this.summaryData) - for(const wwn in this.data.data.summary){ - const deviceSummary = this.data.data.summary[wwn] - if (!deviceSummary.temp_history){ + for (const wwn in this.summaryData) { + const deviceSummary = this.summaryData[wwn] + if (!deviceSummary.temp_history) { continue } @@ -206,7 +216,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy } } }, - xaxis : { + xaxis: { type: 'datetime' } }; @@ -216,11 +226,11 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy // @ Public methods // ----------------------------------------------------------------------------------------------------- - deviceSummariesForHostGroup(hostGroupWWNs: string[]): any[] { - const deviceSummaries = [] - for(const wwn of hostGroupWWNs){ - if(this.data.data.summary[wwn]){ - deviceSummaries.push(this.data.data.summary[wwn]) + deviceSummariesForHostGroup(hostGroupWWNs: string[]): DeviceSummaryModel[] { + const deviceSummaries: DeviceSummaryModel[] = [] + for (const wwn of hostGroupWWNs) { + if (this.summaryData[wwn]) { + deviceSummaries.push(this.summaryData[wwn]) } } return deviceSummaries @@ -235,7 +245,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy } 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" */ - changeSummaryTempDuration(durationKey: string){ + changeSummaryTempDuration(durationKey: string): void { this.tempDurationKey = durationKey - this._smartService.getSummaryTempData(durationKey) - .subscribe((data) => { + this._dashboardService.getSummaryTempData(durationKey) + .subscribe((tempHistoryData) => { // 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}`) - this.data.data.summary[wwn].temp_history = data.data.temp_history[wwn] || [] + this.summaryData[wwn].temp_history = tempHistoryData[wwn] || [] } // Prepare the chart series data diff --git a/webapp/frontend/src/app/modules/dashboard/dashboard.resolvers.ts b/webapp/frontend/src/app/modules/dashboard/dashboard.resolvers.ts index eb29b48..a235061 100644 --- a/webapp/frontend/src/app/modules/dashboard/dashboard.resolvers.ts +++ b/webapp/frontend/src/app/modules/dashboard/dashboard.resolvers.ts @@ -1,13 +1,13 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { Observable } from 'rxjs'; -import { DashboardService } from 'app/modules/dashboard/dashboard.service'; +import {Injectable} from '@angular/core'; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router'; +import {Observable} from 'rxjs'; +import {DashboardService} from 'app/modules/dashboard/dashboard.service'; +import {DeviceSummaryModel} from 'app/core/models/device-summary-model'; @Injectable({ providedIn: 'root' }) -export class DashboardResolver implements Resolve -{ +export class DashboardResolver implements Resolve { /** * Constructor * @@ -29,8 +29,7 @@ export class DashboardResolver implements Resolve * @param route * @param state */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable - { + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<{ [p: string]: DeviceSummaryModel }> { return this._dashboardService.getSummaryData(); } } diff --git a/webapp/frontend/src/app/modules/dashboard/dashboard.service.spec.ts b/webapp/frontend/src/app/modules/dashboard/dashboard.service.spec.ts new file mode 100644 index 0000000..75a7679 --- /dev/null +++ b/webapp/frontend/src/app/modules/dashboard/dashboard.service.spec.ts @@ -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; + + 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); + }); +}); diff --git a/webapp/frontend/src/app/modules/dashboard/dashboard.service.ts b/webapp/frontend/src/app/modules/dashboard/dashboard.service.ts index 185da9d..35764d0 100644 --- a/webapp/frontend/src/app/modules/dashboard/dashboard.service.ts +++ b/webapp/frontend/src/app/modules/dashboard/dashboard.service.ts @@ -1,16 +1,19 @@ -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; -import { getBasePath } from 'app/app.routing'; +import {Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {map, tap} from 'rxjs/operators'; +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({ providedIn: 'root' }) -export class DashboardService -{ +export class DashboardService { // Observables - private _data: BehaviorSubject; + private _data: BehaviorSubject<{ [p: string]: DeviceSummaryModel }>; /** * Constructor @@ -32,8 +35,7 @@ export class DashboardService /** * Getter for data */ - get data$(): Observable - { + get data$(): Observable<{ [p: string]: DeviceSummaryModel }> { return this._data.asObservable(); } @@ -44,22 +46,28 @@ export class DashboardService /** * Get data */ - getSummaryData(): Observable - { + getSummaryData(): Observable<{ [key: string]: DeviceSummaryModel }> { 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); }) ); } - getSummaryTempData(durationKey: string): Observable - { + getSummaryTempData(durationKey: string): Observable<{ [key: string]: SmartTemperatureModel[] }> { const params = {} - if(durationKey){ + if (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 + }) + ); } } diff --git a/webapp/frontend/src/app/modules/detail/detail.component.spec.ts b/webapp/frontend/src/app/modules/detail/detail.component.spec.ts deleted file mode 100644 index 149b9be..0000000 --- a/webapp/frontend/src/app/modules/detail/detail.component.spec.ts +++ /dev/null @@ -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; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ DetailComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(DetailComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/webapp/frontend/src/app/modules/detail/detail.component.ts b/webapp/frontend/src/app/modules/detail/detail.component.ts index f37910a..0e29652 100644 --- a/webapp/frontend/src/app/modules/detail/detail.component.ts +++ b/webapp/frontend/src/app/modules/detail/detail.component.ts @@ -1,18 +1,21 @@ -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 {MatTableDataSource} from '@angular/material/table'; -import {MatSort} from '@angular/material/sort'; -import {Subject} from 'rxjs'; +import {AppConfig} from 'app/core/config/app.config'; import {DetailService} from './detail.service'; -import {takeUntil} from 'rxjs/operators'; import {DetailSettingsComponent} from 'app/layout/common/detail-settings/detail-settings.component'; import {MatDialog} from '@angular/material/dialog'; -import humanizeDuration from 'humanize-duration'; +import {MatSort} from '@angular/material/sort'; +import {MatTableDataSource} from '@angular/material/table'; +import {Subject} from 'rxjs'; import {TreoConfigService} from '@treo/services/config'; -import {AppConfig} from 'app/core/config/app.config'; import {animate, state, style, transition, trigger} from '@angular/animations'; 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'; // from Constants.go - these must match const AttributeStatusPassed = 0 @@ -22,9 +25,9 @@ const AttributeStatusFailedScrutiny = 4 @Component({ - selector: 'detail', - templateUrl: './detail.component.html', - styleUrls: ['./detail.component.scss'], + selector: 'detail', + templateUrl: './detail.component.html', + styleUrls: ['./detail.component.scss'], animations: [ trigger('detailExpand', [ state('collapsed', style({height: '0px', minHeight: '0'})), @@ -40,22 +43,23 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { * Constructor * * @param {DetailService} _detailService + * @param {MatDialog} dialog + * @param {TreoConfigService} _configService + * @param {string} locale */ constructor( private _detailService: DetailService, public dialog: MatDialog, private _configService: TreoConfigService, @Inject(LOCALE_ID) public locale: string - - ) - { + ) { // Set the private defaults this._unsubscribeAll = new Subject(); // Set the defaults this.smartAttributeDataSource = new MatTableDataSource(); // 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; @@ -65,14 +69,15 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { onlyCritical = true; // data: any; - expandedAttribute: any | null; + expandedAttribute: SmartAttributeModel | null; - metadata: any; - device: any; - smart_results: any[]; + metadata: { [p: string]: AttributeMetadataModel } | { [p: number]: AttributeMetadataModel }; + device: DeviceModel; + // tslint:disable-next-line:variable-name + smart_results: SmartModel[]; commonSparklineOptions: Partial; - smartAttributeDataSource: MatTableDataSource; + smartAttributeDataSource: MatTableDataSource; smartAttributeTableColumns: string[]; @ViewChild('smartAttributeTable', {read: MatSort}) @@ -91,8 +96,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { /** * On init */ - ngOnInit(): void - { + ngOnInit(): void { // Subscribe to config changes this._configService.config$ .pipe(takeUntil(this._unsubscribeAll)) @@ -104,13 +108,13 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { // Get the data this._detailService.data$ .pipe(takeUntil(this._unsubscribeAll)) - .subscribe((data) => { + .subscribe((respWrapper) => { // Store the data // this.data = data; - this.device = data.data.device; - this.smart_results = data.data.smart_results - this.metadata = data.metadata; + this.device = respWrapper.data.device; + this.smart_results = respWrapper.data.smart_results + this.metadata = respWrapper.metadata; // Store the table data @@ -124,8 +128,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { /** * After view init */ - ngAfterViewInit(): void - { + ngAfterViewInit(): void { // Make the data source sortable this.smartAttributeDataSource.sort = this.smartAttributeTableMatSort; } @@ -133,8 +136,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { /** * On destroy */ - ngOnDestroy(): void - { + ngOnDestroy(): void { // Unsubscribe from all subscriptions this._unsubscribeAll.next(); this._unsubscribeAll.complete(); @@ -147,22 +149,23 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { getAttributeStatusName(attributeStatus: number): string { // tslint:disable:no-bitwise - if(attributeStatus === AttributeStatusPassed){ + if (attributeStatus === AttributeStatusPassed) { return 'passed' - } else if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0 || (attributeStatus & AttributeStatusFailedSmart) !== 0 ){ + } else if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0 || (attributeStatus & AttributeStatusFailedSmart) !== 0) { return 'failed' - } else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0){ + } else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0) { return 'warn' } return '' // tslint:enable:no-bitwise } + getAttributeScrutinyStatusName(attributeStatus: number): string { // tslint:disable:no-bitwise - if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0){ + if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0) { return 'failed' - } else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0){ + } else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0) { return 'warn' } else { return 'passed' @@ -172,7 +175,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { getAttributeSmartStatusName(attributeStatus: number): string { // tslint:disable:no-bitwise - if ((attributeStatus & AttributeStatusFailedSmart) !== 0){ + if ((attributeStatus & AttributeStatusFailedSmart) !== 0) { return 'failed' } else { return 'passed' @@ -181,138 +184,140 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { } - getAttributeName(attribute_data): string { - const attribute_metadata = this.metadata[attribute_data.attribute_id] - if(!attribute_metadata){ + getAttributeName(attributeData: SmartAttributeModel): string { + const attributeMetadata = this.metadata[attributeData.attribute_id] + if (!attributeMetadata) { return 'Unknown Attribute Name' } else { - return attribute_metadata.display_name + return attributeMetadata.display_name } } - getAttributeDescription(attribute_data){ - const attribute_metadata = this.metadata[attribute_data.attribute_id] - if(!attribute_metadata){ + + getAttributeDescription(attributeData: SmartAttributeModel): string { + const attributeMetadata = this.metadata[attributeData.attribute_id] + if (!attributeMetadata) { return 'Unknown' } else { - return attribute_metadata.description + return attributeMetadata.description } - return } - getAttributeValue(attribute_data){ - if(this.isAta()) { - const attribute_metadata = this.metadata[attribute_data.attribute_id] - if(!attribute_metadata){ - return attribute_data.value - } else if (attribute_metadata.display_type == 'raw') { - return attribute_data.raw_value - } else if (attribute_metadata.display_type == 'transformed' && attribute_data.transformed_value) { - return attribute_data.transformed_value + getAttributeValue(attributeData: SmartAttributeModel): number { + if (this.isAta()) { + const attributeMetadata = this.metadata[attributeData.attribute_id] + if (!attributeMetadata) { + return attributeData.value + } else if (attributeMetadata.display_type === 'raw') { + return attributeData.raw_value + } else if (attributeMetadata.display_type === 'transformed' && attributeData.transformed_value) { + return attributeData.transformed_value } else { - return attribute_data.value + return attributeData.value } - } - else{ - return attribute_data.value + } else { + return attributeData.value } } - getAttributeValueType(attribute_data){ - if(this.isAta()) { - const attribute_metadata = this.metadata[attribute_data.attribute_id] - if(!attribute_metadata){ + getAttributeValueType(attributeData: SmartAttributeModel): string { + if (this.isAta()) { + const attributeMetadata = this.metadata[attributeData.attribute_id] + if (!attributeMetadata) { return '' } else { - return attribute_metadata.display_type + return attributeMetadata.display_type } } else { return '' } } - getAttributeIdeal(attribute_data){ - if(this.isAta()){ - return this.metadata[attribute_data.attribute_id]?.display_type == 'raw' ? this.metadata[attribute_data.attribute_id]?.ideal : '' + getAttributeIdeal(attributeData: SmartAttributeModel): string { + if (this.isAta()) { + return this.metadata[attributeData.attribute_id]?.display_type === 'raw' ? this.metadata[attributeData.attribute_id]?.ideal : '' } else { - return this.metadata[attribute_data.attribute_id]?.ideal + return this.metadata[attributeData.attribute_id]?.ideal } } - getAttributeWorst(attribute_data){ - const attribute_metadata = this.metadata[attribute_data.attribute_id] - if(!attribute_metadata){ - return attribute_data.worst + getAttributeWorst(attributeData: SmartAttributeModel): number | string { + const attributeMetadata = this.metadata[attributeData.attribute_id] + if (!attributeMetadata) { + return attributeData.worst } else { - return attribute_metadata?.display_type == 'normalized' ? attribute_data.worst : '' + return attributeMetadata?.display_type === 'normalized' ? attributeData.worst : '' } } - getAttributeThreshold(attribute_data){ - if(this.isAta()){ - const attribute_metadata = this.metadata[attribute_data.attribute_id] - if(!attribute_metadata || attribute_metadata.display_type == 'normalized'){ - return attribute_data.thresh + getAttributeThreshold(attributeData: SmartAttributeModel): number | string { + if (this.isAta()) { + const attributeMetadata = this.metadata[attributeData.attribute_id] + if (!attributeMetadata || attributeMetadata.display_type === 'normalized') { + return attributeData.thresh } else { // if(this.data.metadata[attribute_data.attribute_id].observed_thresholds){ // // } else { // } // return '' - return attribute_data.thresh + return attributeData.thresh } } else { - return (attribute_data.thresh == -1 ? '' : attribute_data.thresh ) + return (attributeData.thresh === -1 ? '' : attributeData.thresh) } } - getAttributeCritical(attribute_data){ - return this.metadata[attribute_data.attribute_id]?.critical + getAttributeCritical(attributeData: SmartAttributeModel): boolean { + 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 } - let attributes_length = 0 + let attributesLength = 0 const attributes = this.smart_results[0]?.attrs 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 { - return this.device.device_protocol == 'ATA' + return this.device.device_protocol === 'ATA' } + isScsi(): boolean { - return this.device.device_protocol == 'SCSI' + return this.device.device_protocol === 'SCSI' } + isNvme(): boolean { - return this.device.device_protocol == 'NVMe' + return this.device.device_protocol === 'NVMe' } - private _generateSmartAttributeTableDataSource(smart_results){ - const smartAttributeDataSource = []; + private _generateSmartAttributeTableDataSource(smartResults: SmartModel[]): SmartAttributeModel[] { + const smartAttributeDataSource: SmartAttributeModel[] = []; - if(smart_results.length == 0){ + if (smartResults.length === 0) { return smartAttributeDataSource } - const latest_smart_result = smart_results[0]; - let attributes = {} - if(this.isScsi()) { + const latestSmartResult = smartResults[0]; + let attributes: { [p: string]: SmartAttributeModel } = {} + if (this.isScsi()) { this.smartAttributeTableColumns = ['status', 'name', 'value', 'thresh', 'history']; - attributes = latest_smart_result.attrs - } else if(this.isNvme()){ + attributes = latestSmartResult.attrs + } else if (this.isNvme()) { this.smartAttributeTableColumns = ['status', 'name', 'value', 'thresh', 'ideal', 'history']; - attributes = latest_smart_result.attrs + attributes = latestSmartResult.attrs } else { // ATA - attributes = latest_smart_result.attrs - this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'thresh','ideal', 'failure', 'history']; + attributes = latestSmartResult.attrs + this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'thresh', 'ideal', 'failure', 'history']; } - for(const attrId in attributes){ + for (const attrId in attributes) { const attr = attributes[attrId] // chart history data @@ -320,18 +325,18 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { const attrHistory = [] - for (const smart_result of smart_results){ + for (const smartResult of smartResults) { // attrHistory.push(this.getAttributeValue(smart_result.attrs[attrId])) const chartDatapoint = { - x: formatDate(smart_result.date, 'MMMM dd, yyyy - HH:mm', this.locale), - y: this.getAttributeValue(smart_result.attrs[attrId]) + x: formatDate(smartResult.date, 'MMMM dd, yyyy - HH:mm', this.locale), + y: this.getAttributeValue(smartResult.attrs[attrId]) } - const attributeStatusName = this.getAttributeStatusName(smart_result.attrs[attrId].status) - if(attributeStatusName === 'failed') { + const attributeStatusName = this.getAttributeStatusName(smartResult.attrs[attrId].status) + if (attributeStatusName === 'failed') { chartDatapoint['strokeColor'] = '#F05252' chartDatapoint['fillColor'] = '#F05252' - } else if (attributeStatusName === 'warn'){ + } else if (attributeStatusName === 'warn') { chartDatapoint['strokeColor'] = '#C27803' chartDatapoint['fillColor'] = '#C27803' } @@ -350,7 +355,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { } // 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) } } @@ -362,8 +367,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { * * @private */ - private _prepareChartData(): void - { + private _prepareChartData(): void { // Account balance this.commonSparklineOptions = { @@ -392,7 +396,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { }, y: { title: { - formatter: function(seriesName) { + formatter: (seriesName) => { return ''; } } @@ -410,27 +414,28 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { }; } - private determineTheme(config:AppConfig): string { + private determineTheme(config: AppConfig): string { if (config.theme === 'system') { return this.systemPrefersDark ? 'dark' : 'light' } else { return config.theme } } + // ----------------------------------------------------------------------------------------------------- // @ Public methods // ----------------------------------------------------------------------------------------------------- - toHex(decimalNumb){ + toHex(decimalNumb: number | string): string { return '0x' + Number(decimalNumb).toString(16).padStart(2, '0').toUpperCase() } - toggleOnlyCritical(){ + + toggleOnlyCritical(): void { this.onlyCritical = !this.onlyCritical this.smartAttributeDataSource.data = this._generateSmartAttributeTableDataSource(this.smart_results); - } - openDialog() { + openDialog(): void { const dialogRef = this.dialog.open(DetailSettingsComponent); dialogRef.afterClosed().subscribe(result => { @@ -444,8 +449,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { * @param index * @param item */ - trackByFn(index: number, item: any): any - { + trackByFn(index: number, item: any): any { return index; // return item.id || index; } diff --git a/webapp/frontend/src/app/modules/detail/detail.resolvers.ts b/webapp/frontend/src/app/modules/detail/detail.resolvers.ts index b416a3d..221cad1 100644 --- a/webapp/frontend/src/app/modules/detail/detail.resolvers.ts +++ b/webapp/frontend/src/app/modules/detail/detail.resolvers.ts @@ -1,13 +1,13 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { Observable } from 'rxjs'; -import { DetailService } from 'app/modules/detail/detail.service'; +import {Injectable} from '@angular/core'; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router'; +import {Observable} from 'rxjs'; +import {DetailService} from 'app/modules/detail/detail.service'; +import {DeviceDetailsResponseWrapper} from 'app/core/models/device-details-response-wrapper'; @Injectable({ providedIn: 'root' }) -export class DetailResolver implements Resolve -{ +export class DetailResolver implements Resolve { /** * Constructor * @@ -29,8 +29,7 @@ export class DetailResolver implements Resolve * @param route * @param state */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable - { + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return this._detailService.getData(route.params.wwn); } } diff --git a/webapp/frontend/src/app/modules/detail/detail.service.spec.ts b/webapp/frontend/src/app/modules/detail/detail.service.spec.ts new file mode 100644 index 0000000..ec6a4b9 --- /dev/null +++ b/webapp/frontend/src/app/modules/detail/detail.service.spec.ts @@ -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; + + 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); + }); + }) +}); diff --git a/webapp/frontend/src/app/modules/detail/detail.service.ts b/webapp/frontend/src/app/modules/detail/detail.service.ts index 5747571..e75cb8b 100644 --- a/webapp/frontend/src/app/modules/detail/detail.service.ts +++ b/webapp/frontend/src/app/modules/detail/detail.service.ts @@ -1,16 +1,16 @@ -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; -import { getBasePath } from 'app/app.routing'; +import {Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {tap} from 'rxjs/operators'; +import {getBasePath} from 'app/app.routing'; +import {DeviceDetailsResponseWrapper} from 'app/core/models/device-details-response-wrapper'; @Injectable({ providedIn: 'root' }) -export class DetailService -{ +export class DetailService { // Observables - private _data: BehaviorSubject; + private _data: BehaviorSubject; /** * Constructor @@ -19,8 +19,7 @@ export class DetailService */ constructor( private _httpClient: HttpClient - ) - { + ) { // Set the private defaults this._data = new BehaviorSubject(null); } @@ -32,8 +31,7 @@ export class DetailService /** * Getter for data */ - get data$(): Observable - { + get data$(): Observable { return this._data.asObservable(); } @@ -44,10 +42,9 @@ export class DetailService /** * Get data */ - getData(wwn): Observable - { + getData(wwn): Observable { return this._httpClient.get(getBasePath() + `/api/device/${wwn}/details`).pipe( - tap((response: any) => { + tap((response: DeviceDetailsResponseWrapper) => { this._data.next(response); }) ); diff --git a/webapp/frontend/src/app/shared/device-title.pipe.spec.ts b/webapp/frontend/src/app/shared/device-title.pipe.spec.ts index 1d64103..992cf9a 100644 --- a/webapp/frontend/src/app/shared/device-title.pipe.spec.ts +++ b/webapp/frontend/src/app/shared/device-title.pipe.spec.ts @@ -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', () => { - it('create an instance', () => { - const pipe = new DeviceTitlePipe(); - expect(pipe).toBeTruthy(); - }); + it('create an instance', () => { + const pipe = new DeviceTitlePipe(); + 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); + }); + }) + }) }); diff --git a/webapp/frontend/src/app/shared/device-title.pipe.ts b/webapp/frontend/src/app/shared/device-title.pipe.ts index 1196fb8..3cabc0f 100644 --- a/webapp/frontend/src/app/shared/device-title.pipe.ts +++ b/webapp/frontend/src/app/shared/device-title.pipe.ts @@ -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({ name: 'deviceTitle' }) export class DeviceTitlePipe implements PipeTransform { - static deviceTitleForType(device: any, titleType: string): string { + static deviceTitleForType(device: DeviceModel, titleType: string): string { const titleParts = [] switch(titleType){ case 'name': @@ -35,7 +36,7 @@ export class DeviceTitlePipe implements PipeTransform { return titleParts.join(' - ') } - static deviceTitleWithFallback(device, titleType: string): string { + static deviceTitleWithFallback(device: DeviceModel, titleType: string): string { console.log(`Displaying Device ${device.wwn} with: ${titleType}`) const titleParts = [] 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) } diff --git a/webapp/frontend/src/app/shared/file-size.pipe.spec.ts b/webapp/frontend/src/app/shared/file-size.pipe.spec.ts new file mode 100644 index 0000000..14973cf --- /dev/null +++ b/webapp/frontend/src/app/shared/file-size.pipe.spec.ts @@ -0,0 +1,35 @@ +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, + 'precision': undefined, + 'result': '1 KB' + },{ + 'bytes': 2_100_000_000, + 'precision': undefined, + 'result': '2.0 GB', + },{ + 'bytes': 1500, + 'precision': 2, + 'result': '1.46 KB', + } + ] + testCases.forEach((test, index) => { + it(`should correctly format bytes ${test.bytes}. (testcase: ${index + 1})`, () => { + // test + const pipe = new FileSizePipe(); + const formatted = pipe.transform(test.bytes, test.precision) + expect(formatted).toEqual(test.result); + }); + }) + }) + +}); diff --git a/webapp/frontend/src/app/shared/temperature.pipe.spec.ts b/webapp/frontend/src/app/shared/temperature.pipe.spec.ts index fc30978..70a4908 100644 --- a/webapp/frontend/src/app/shared/temperature.pipe.spec.ts +++ b/webapp/frontend/src/app/shared/temperature.pipe.spec.ts @@ -1,8 +1,83 @@ import { TemperaturePipe } from './temperature.pipe'; describe('TemperaturePipe', () => { - it('create an instance', () => { - const pipe = new TemperaturePipe(); - expect(pipe).toBeTruthy(); - }); + it('create an instance', () => { + const pipe = new TemperaturePipe(); + expect(pipe).toBeTruthy(); + }); + + + describe('#celsiusToFahrenheit', () => { + const testCases = [ + { + 'c': -273.15, + 'f': -460, + },{ + 'c': -34.44, + 'f': -30, + },{ + 'c': -23.33, + 'f': -10, + },{ + 'c': -17.78, + 'f': -0, + },{ + 'c': 0, + 'f': 32, + },{ + 'c': 10, + 'f': 50, + },{ + 'c': 26.67, + 'f': 80, + },{ + 'c': 37, + 'f': 99, + },{ + 'c': 60, + 'f': 140, + } + ] + testCases.forEach((test, index) => { + it(`should correctly convert ${test.c}, Celsius to Fahrenheit (testcase: ${index + 1})`, () => { + // test + const numb = TemperaturePipe.celsiusToFahrenheit(test.c) + const roundNumb = Math.round(numb); + expect(roundNumb).toEqual(test.f); + }); + }) + }); + + describe('#formatTemperature',() => { + const testCases = [ + { + 'c': 26.67, + 'unit': 'celsius', + 'includeUnits': true, + 'result': '26.67°C' + },{ + 'c': 26.67, + 'unit': 'celsius', + 'includeUnits': false, + 'result': '26.67', + },{ + 'c': 26.67, + 'unit': 'fahrenheit', + 'includeUnits': true, + 'result': '80.006°F', + },{ + 'c': 26.67, + 'unit': 'fahrenheit', + 'includeUnits': false, + 'result': '80.006', + } + ] + testCases.forEach((test, index) => { + it(`should correctly format temperature ${test.c} to ${test.unit} ${test.includeUnits ? 'with' : 'without'} unit. (testcase: ${index + 1})`, () => { + // test + const formatted = TemperaturePipe.formatTemperature(test.c, test.unit, test.includeUnits) + expect(formatted).toEqual(test.result); + }); + }) + }) });