Merge pull request #328 from AnalogJ/beta

pre v0.4.16
pull/342/head
Jason Kulatunga 2 years ago committed by GitHub
commit 550fb542d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -3,11 +3,25 @@ name: CI
on: [pull_request] on: [pull_request]
jobs: jobs:
test: test-frontend:
name: Test name: Test Frontend
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Test Frontend
run: |
make binary-frontend-test-coverage
- name: Upload coverage
uses: actions/upload-artifact@v3
with:
name: coverage
path: ${{ github.workspace }}/webapp/frontend/coverage/lcov.info
retention-days: 1
test-backend:
name: Test Backend
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: ghcr.io/packagrio/packagr:latest-golang container: ghcr.io/packagrio/packagr:latest-golang
# Service containers to run with `build` (Required for end-to-end testing) # Service containers to run with `build` (Required for end-to-end testing)
services: services:
influxdb: influxdb:
@ -22,7 +36,6 @@ jobs:
ports: ports:
- 8086:8086 - 8086:8086
env: env:
PROJECT_PATH: /go/src/github.com/analogj/scrutiny
STATIC: true STATIC: true
steps: steps:
- name: Git - name: Git
@ -32,16 +45,36 @@ jobs:
git --version git --version
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Test - name: Test Backend
run: | run: |
make binary-clean binary-test-coverage make binary-clean binary-test-coverage
- name: Generate coverage report - name: Upload coverage
uses: actions/upload-artifact@v3
with:
name: coverage
path: ${{ github.workspace }}/coverage.txt
retention-days: 1
test-coverage:
name: Test Coverage Upload
needs:
- test-backend
- test-frontend
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Download coverage reports
uses: actions/download-artifact@v3
with:
name: coverage
- name: Upload coverage reports
uses: codecov/codecov-action@v2 uses: codecov/codecov-action@v2
with: with:
files: ${{ github.workspace }}/coverage.txt files: ${{ github.workspace }}/coverage.txt,${{ github.workspace }}/lcov.info
flags: unittests flags: unittests
fail_ci_if_error: true fail_ci_if_error: true
verbose: true verbose: true
build: build:
name: Build ${{ matrix.cfg.goos }}/${{ matrix.cfg.goarch }} name: Build ${{ matrix.cfg.goos }}/${{ matrix.cfg.goarch }}
runs-on: ${{ matrix.cfg.on }} runs-on: ${{ matrix.cfg.on }}
@ -66,6 +99,9 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
- uses: actions/setup-go@v3
with:
go-version: '^1.18.3'
- name: Build Binaries - name: Build Binaries
run: | run: |
make binary-clean binary-all make binary-clean binary-all

@ -9,8 +9,9 @@ Depending on the functionality you are adding, you may need to setup a developme
# Modifying the Scrutiny Backend Server (API) # Modifying the Scrutiny Backend Server (API)
1. install the [Go runtime](https://go.dev/doc/install) (v1.17+) 1. install the [Go runtime](https://go.dev/doc/install) (v1.18+)
2. download the `scrutiny-web-frontend.tar.gz` for the [latest release](https://github.com/AnalogJ/scrutiny/releases/latest). Extract to a folder named `dist` 2. download the `scrutiny-web-frontend.tar.gz` for
the [latest release](https://github.com/AnalogJ/scrutiny/releases/latest). Extract to a folder named `dist`
3. create a `scrutiny.yaml` config file 3. create a `scrutiny.yaml` config file
```yaml ```yaml
# config file for local development. store as scrutiny.yaml # config file for local development. store as scrutiny.yaml
@ -62,7 +63,7 @@ The frontend is written in Angular. If you're working on the frontend and can us
If you're developing a feature that requires changes to the backend and the frontend, or a frontend feature that requires real data, If you're developing a feature that requires changes to the backend and the frontend, or a frontend feature that requires real data,
you'll need to follow the steps below: you'll need to follow the steps below:
1. install the [Go runtime](https://go.dev/doc/install) (v1.17+) 1. install the [Go runtime](https://go.dev/doc/install) (v1.18+)
2. install [NodeJS](https://nodejs.org/en/download/) 2. install [NodeJS](https://nodejs.org/en/download/)
3. create a `scrutiny.yaml` config file 3. create a `scrutiny.yaml` config file
```yaml ```yaml

@ -1,4 +1,5 @@
.ONESHELL: # Applies to every targets in the file! .ONESHELL instructs make to invoke a single instance of the shell and provide it with the entire recipe, regardless of how many lines it contains. .ONESHELL: # Applies to every targets in the file! .ONESHELL instructs make to invoke a single instance of the shell and provide it with the entire recipe, regardless of how many lines it contains.
.SHELLFLAGS = -ec
######################################################################################################################## ########################################################################################################################
# Global Env Settings # Global Env Settings
@ -89,6 +90,10 @@ ifneq ($(OS),Windows_NT)
./$(WEB_BINARY_NAME) || true ./$(WEB_BINARY_NAME) || true
endif endif
########################################################################################################################
# Binary
########################################################################################################################
.PHONY: binary-frontend .PHONY: binary-frontend
# reduce logging, disable angular-cli analytics for ci environment # reduce logging, disable angular-cli analytics for ci environment
binary-frontend: export NPM_CONFIG_LOGLEVEL = warn binary-frontend: export NPM_CONFIG_LOGLEVEL = warn
@ -100,6 +105,12 @@ binary-frontend:
npm ci npm ci
npm run build:prod -- --output-path=$(CURDIR)/dist npm run build:prod -- --output-path=$(CURDIR)/dist
.PHONY: binary-frontend-test-coverage
# reduce logging, disable angular-cli analytics for ci environment
binary-frontend-test-coverage:
cd webapp/frontend
npm ci
npx ng test --watch=false --browsers=ChromeHeadless --code-coverage
######################################################################################################################## ########################################################################################################################
# Docker # Docker

@ -9,6 +9,7 @@ import (
"github.com/analogj/scrutiny/collector/pkg/detect" "github.com/analogj/scrutiny/collector/pkg/detect"
"github.com/analogj/scrutiny/collector/pkg/errors" "github.com/analogj/scrutiny/collector/pkg/errors"
"github.com/analogj/scrutiny/collector/pkg/models" "github.com/analogj/scrutiny/collector/pkg/models"
"github.com/samber/lo"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"net/url" "net/url"
"os" "os"
@ -56,11 +57,16 @@ func (mc *MetricsCollector) Run() error {
Logger: mc.logger, Logger: mc.logger,
Config: mc.config, Config: mc.config,
} }
detectedStorageDevices, err := deviceDetector.Start() rawDetectedStorageDevices, err := deviceDetector.Start()
if err != nil { if err != nil {
return err return err
} }
//filter any device with empty wwn (they are invalid)
detectedStorageDevices := lo.Filter[models.Device](rawDetectedStorageDevices, func(dev models.Device, _ int) bool {
return len(dev.WWN) > 0
})
mc.logger.Infoln("Sending detected devices to API, for filtering & validation") mc.logger.Infoln("Sending detected devices to API, for filtering & validation")
jsonObj, _ := json.Marshal(detectedStorageDevices) jsonObj, _ := json.Marshal(detectedStorageDevices)
mc.logger.Debugf("Detected devices: %v", string(jsonObj)) mc.logger.Debugf("Detected devices: %v", string(jsonObj))

@ -5,7 +5,7 @@
######## ########
FROM golang:1.17-bullseye as backendbuild FROM golang:1.18-bullseye as backendbuild
WORKDIR /go/src/github.com/analogj/scrutiny WORKDIR /go/src/github.com/analogj/scrutiny
COPY . /go/src/github.com/analogj/scrutiny COPY . /go/src/github.com/analogj/scrutiny

@ -4,7 +4,7 @@
######## ########
FROM golang:1.17-bullseye as backendbuild FROM golang:1.18-bullseye as backendbuild
WORKDIR /go/src/github.com/analogj/scrutiny WORKDIR /go/src/github.com/analogj/scrutiny

@ -5,7 +5,7 @@
######## ########
FROM golang:1.17-bullseye as backendbuild FROM golang:1.18-bullseye as backendbuild
WORKDIR /go/src/github.com/analogj/scrutiny WORKDIR /go/src/github.com/analogj/scrutiny

@ -1,17 +1,18 @@
# Officially Supported NAS OS's # Officially Supported NAS/OS's
These are the officially supported NAS OS's (with documentation and setup guides). These are the officially supported NAS OS's (with documentation and setup guides). Once a guide is created (
Once a guide is created (in `docs/guides/`) it will be linked here. in `docs/guides/` or elsewhere) it will be linked here.
- [ ] freenas/truenas - [x] [freenas/truenas](https://blog.stefandroid.com/2022/01/14/smart-scrutiny.html)
- [x] [unraid](./INSTALL_UNRAID.md) - [x] [unraid](./INSTALL_UNRAID.md)
- [ ] ESXI - [ ] ESXI
- [ ] Proxmox - [ ] Proxmox
- [x] Synology(./INSTALL_SYNOLOGY_COLLECTOR.md) - [x] [Synology](./INSTALL_SYNOLOGY_COLLECTOR.md)
- [ ] OMV - [ ] OMV
- [ ] Amahi - [ ] Amahi
- [ ] Running in a LXC container - [ ] Running in a LXC container
- [x] [PFSense](./INSTALL_UNRAID.md) - [x] [PFSense](./INSTALL_UNRAID.md)
- [ ] QNAP - [x] QNAP
- [ ] RockStor - [x] [RockStor](https://rockstor.com/docs/interface/docker-based-rock-ons/scrutiny.html)
- [ ] Solaris/OmniOS CE Support
- [ ] Kubernetes

@ -113,11 +113,25 @@ instead of the block device (`/dev/nvme0n1`). See [#209](https://github.com/Anal
### ATA ### ATA
### USB Devices
The following information is extracted from [#266](https://github.com/AnalogJ/scrutiny/issues/266)
External HDDs support two modes of operation usb-storage (old, slower, stable) and uas (new, faster, sometimes unstable)
. On some external HDDs, uas mode does not properly pass through SMART information, or even causes hardware issues, so
it has been disabled by the kernel. No amount of smartctl parameters will fix this, as it is being rejected by the
kernel. This is especially true with Seagate HDDs. One solution is to force these devices into usb-storage mode, which
will incur some performance penalty, but may work well enough for you. More info:
- https://smartmontools.org/wiki/Supported_USB-Devices
- https://smartmontools.org/wiki/SAT-with-UAS-Linux
- https://forums.raspberrypi.com/viewtopic.php?t=245931
### Exit Codes ### Exit Codes
If you see an error message similar to `smartctl returned an error code (2) while processing /dev/sda`, this means that If you see an error message similar to `smartctl returned an error code (2) while processing /dev/sda`, this means that
`smartctl` (not Scrutiny) exited with an error code. Scrutiny will attempt to print a helpful error message to help you debug, `smartctl` (not Scrutiny) exited with an error code. Scrutiny will attempt to print a helpful error message to help you
but you can look at the table (and associated links) below to debug `smartctl`. debug, but you can look at the table (and associated links) below to debug `smartctl`.
> smartctl Return Values > smartctl Return Values
> The return values of smartctl are defined by a bitmask. If all is well with the disk, the return value (exit status) of > The return values of smartctl are defined by a bitmask. If all is well with the disk, the return value (exit status) of

@ -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), As discussed in [#248](https://github.com/AnalogJ/scrutiny/issues/248) and [#234](https://github.com/AnalogJ/scrutiny/issues/234),
this usually related to either: this usually related to either:
- Upgrading from the LSIO Scrutiny image to the Official Scrutiny image, without removing LSIO specific environmental variables - Upgrading from the LSIO Scrutiny image to the Official Scrutiny image, without removing LSIO specific environmental
- remove the `SCRUTINY_WEB=true` and `SCRUTINY_COLLECTOR=true` environmental variables. They were used by the LSIO image, but are unnecessary and cause issues with the official Scrutiny image. variables
- Updated versions of the [LSIO Scrutiny images are broken](https://github.com/linuxserver/docker-scrutiny/issues/22), as they have not installed InfluxDB which is a required dependency of Scrutiny v0.4.x - remove the `SCRUTINY_WEB=true` and `SCRUTINY_COLLECTOR=true` environmental variables. They were used by the LSIO
- You can revert to an earlier version of the LSIO image (`lscr.io/linuxserver/scrutiny:060ac7b8-ls34`), or just change to the official Scrutiny image (`ghcr.io/analogj/scrutiny:master-omnibus`) image, but are unnecessary and cause issues with the official Scrutiny image.
- Updated versions of the [LSIO Scrutiny images are broken](https://github.com/linuxserver/docker-scrutiny/issues/22),
as they have not installed InfluxDB which is a required dependency of Scrutiny v0.4.x
- You can revert to an earlier version of the LSIO image (`lscr.io/linuxserver/scrutiny:060ac7b8-ls34`), or just
change to the official Scrutiny image (`ghcr.io/analogj/scrutiny:master-omnibus`)
Here's a couple of confirmed working docker-compose files that you may want to look at: Here's a couple of confirmed working docker-compose files that you may want to look at:
- https://github.com/AnalogJ/scrutiny/blob/master/docker/example.hubspoke.docker-compose.yml - https://github.com/AnalogJ/scrutiny/blob/master/docker/example.hubspoke.docker-compose.yml
- https://github.com/AnalogJ/scrutiny/blob/master/docker/example.omnibus.docker-compose.yml - https://github.com/AnalogJ/scrutiny/blob/master/docker/example.omnibus.docker-compose.yml
## Bring your own InfluxDB
> WARNING: Most users should not follow these steps. This is ONLY for users who have an EXISTING InfluxDB installation which contains data from multiple services.
> The Scrutiny Docker omnibus image includes an empty InfluxDB instance which it can configure.
> If you're deploying manually or via Hub/Spoke, you can just follow the installation instructions, Scrutiny knows how
> to run the first-time setup automatically.
The goal here is to create an InfluxDB API key with minimal permissions for use by Scrutiny.
- Create Scrutiny buckets (`metrics`, `metrics_weekly`, `metrics_monthly`, `metrics_yearly`) with placeholder config
- Create Downsampling tasks (`tsk-weekly-aggr`, `tsk-monthly-aggr`, `tsk-yearly-aggr`) with placeholder script.
- Create API token with restricted scope
- NOTE: Placeholder bucket & task configuration will be replaced automatically by Scrutiny during startup
The placeholder buckets and tasks need to be created before the API token can be created, as the resource ID's need to
exist for the scope restriction to work.
Scopes:
- `orgs`: read - required for scrutiny to find it's configured org_id
- `tasks`: scrutiny specific read/write access - Scrutiny only needs access to the downsampling tasks you created above
- `buckets`: scrutiny specific read/write access - Scrutiny only needs access to the buckets you created above
### Setup Environmental Variables
```bash
# replace the following values with correct values for your InfluxDB installation
export INFLUXDB_ADMIN_TOKEN=pCqRq7xxxxxx-FZgNLfstIs0w==
export INFLUXDB_ORG_ID=b2495xxxxx
export INFLUXDB_HOSTNAME=http://localhost:8086
# if you want to change the bucket name prefix below, you'll also need to update the setting in the scrutiny.yaml config file.
export INFLUXDB_SCRUTINY_BUCKET_BASENAME=metrics
```
### Create placeholder buckets
<details>
<summary>Click to expand!</summary>
```bash
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/buckets \
-H "Content-Type: application/json" \
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
--data-binary @- << EOF
{
"name": "${INFLUXDB_SCRUTINY_BUCKET_BASENAME}",
"orgID": "${INFLUXDB_ORG_ID}",
"retentionRules": []
}
EOF
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/buckets \
-H "Content-Type: application/json" \
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
--data-binary @- << EOF
{
"name": "${INFLUXDB_SCRUTINY_BUCKET_BASENAME}_weekly",
"orgID": "${INFLUXDB_ORG_ID}",
"retentionRules": []
}
EOF
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/buckets \
-H "Content-Type: application/json" \
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
--data-binary @- << EOF
{
"name": "${INFLUXDB_SCRUTINY_BUCKET_BASENAME}_monthly",
"orgID": "${INFLUXDB_ORG_ID}",
"retentionRules": []
}
EOF
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/buckets \
-H "Content-Type: application/json" \
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
--data-binary @- << EOF
{
"name": "${INFLUXDB_SCRUTINY_BUCKET_BASENAME}_yearly",
"orgID": "${INFLUXDB_ORG_ID}",
"retentionRules": []
}
EOF
```
</details>
### Create placeholder tasks
<details>
<summary>Click to expand!</summary>
```bash
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/tasks \
-H "Content-Type: application/json" \
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
--data-binary @- << EOF
{
"orgID": "${INFLUXDB_ORG_ID}",
"flux": "option task = {name: \"tsk-weekly-aggr\", every: 1y} \nyield now()"
}
EOF
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/tasks \
-H "Content-Type: application/json" \
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
--data-binary @- << EOF
{
"orgID": "${INFLUXDB_ORG_ID}",
"flux": "option task = {name: \"tsk-monthly-aggr\", every: 1y} \nyield now()"
}
EOF
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/tasks \
-H "Content-Type: application/json" \
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
--data-binary @- << EOF
{
"orgID": "${INFLUXDB_ORG_ID}",
"flux": "option task = {name: \"tsk-yearly-aggr\", every: 1y} \nyield now()"
}
EOF
```
</details>
### Create InfluxDB API Token
<details>
<summary>Click to expand!</summary>
```bash
# replace these values with placeholder bucket and task ids from your InfluxDB installation.
export INFLUXDB_SCRUTINY_BASE_BUCKET_ID=1e0709xxxx
export INFLUXDB_SCRUTINY_WEEKLY_BUCKET_ID=1af03dexxxxx
export INFLUXDB_SCRUTINY_MONTHLY_BUCKET_ID=b3c59c7xxxxx
export INFLUXDB_SCRUTINY_YEARLY_BUCKET_ID=f381d8cxxxxx
export INFLUXDB_SCRUTINY_WEEKLY_TASK_ID=09a64ecxxxxx
export INFLUXDB_SCRUTINY_MONTHLY_TASK_ID=09a64xxxxx
export INFLUXDB_SCRUTINY_YEARLY_TASK_ID=09a64ecxxxxx
curl -sS -X POST ${INFLUXDB_HOSTNAME}/api/v2/authorizations \
-H "Content-Type: application/json" \
-H "Authorization: Token ${INFLUXDB_ADMIN_TOKEN}" \
--data-binary @- << EOF
{
"description": "scrutiny - restricted scope token",
"orgID": "${INFLUXDB_ORG_ID}",
"permissions": [
{
"action": "read",
"resource": {
"type": "orgs"
}
},
{
"action": "read",
"resource": {
"type": "tasks"
}
},
{
"action": "write",
"resource": {
"type": "tasks",
"id": "${INFLUXDB_SCRUTINY_WEEKLY_TASK_ID}",
"orgID": "${INFLUXDB_ORG_ID}"
}
},
{
"action": "write",
"resource": {
"type": "tasks",
"id": "${INFLUXDB_SCRUTINY_MONTHLY_TASK_ID}",
"orgID": "${INFLUXDB_ORG_ID}"
}
},
{
"action": "write",
"resource": {
"type": "tasks",
"id": "${INFLUXDB_SCRUTINY_YEARLY_TASK_ID}",
"orgID": "${INFLUXDB_ORG_ID}"
}
},
{
"action": "read",
"resource": {
"type": "buckets",
"id": "${INFLUXDB_SCRUTINY_BASE_BUCKET_ID}",
"orgID": "${INFLUXDB_ORG_ID}"
}
},
{
"action": "write",
"resource": {
"type": "buckets",
"id": "${INFLUXDB_SCRUTINY_BASE_BUCKET_ID}",
"orgID": "${INFLUXDB_ORG_ID}"
}
},
{
"action": "read",
"resource": {
"type": "buckets",
"id": "${INFLUXDB_SCRUTINY_WEEKLY_BUCKET_ID}",
"orgID": "${INFLUXDB_ORG_ID}"
}
},
{
"action": "write",
"resource": {
"type": "buckets",
"id": "${INFLUXDB_SCRUTINY_WEEKLY_BUCKET_ID}",
"orgID": "${INFLUXDB_ORG_ID}"
}
},
{
"action": "read",
"resource": {
"type": "buckets",
"id": "${INFLUXDB_SCRUTINY_MONTHLY_BUCKET_ID}",
"orgID": "${INFLUXDB_ORG_ID}"
}
},
{
"action": "write",
"resource": {
"type": "buckets",
"id": "${INFLUXDB_SCRUTINY_MONTHLY_BUCKET_ID}",
"orgID": "${INFLUXDB_ORG_ID}"
}
},
{
"action": "read",
"resource": {
"type": "buckets",
"id": "${INFLUXDB_SCRUTINY_YEARLY_BUCKET_ID}",
"orgID": "${INFLUXDB_ORG_ID}"
}
},
{
"action": "write",
"resource": {
"type": "buckets",
"id": "${INFLUXDB_SCRUTINY_YEARLY_BUCKET_ID}",
"orgID": "${INFLUXDB_ORG_ID}"
}
}
]
}
EOF
```
</details>
### Save InfluxDB API Token
After running the Curl command above, you'll see a JSON response that looks like the following:
```json
{
"token": "ksVU2t5SkQwYkvIxxxxxxxYt2xUt0uRKSbSF1Po0UQ==",
"status": "active",
"description": "scrutiny - restricted scope token",
"orgID": "b2495586xxxx",
"org": "my-org",
"user": "admin",
"permissions": [
{
"action": "read",
"resource": {
"type": "orgs"
}
},
{
"action": "read",
"resource": {
"type": "tasks"
}
},
{
"action": "write",
"resource": {
"type": "tasks",
"id": "09a64exxxxx",
"orgID": "b24955860xxxxx",
"org": "my-org"
}
},
...
]
}
```
You must copy the token field from the JSON response, and save it in your `scrutiny.yaml` config file. After that's
done, you can start the Scrutiny server

@ -1,6 +1,6 @@
module github.com/analogj/scrutiny module github.com/analogj/scrutiny
go 1.17 go 1.18
require ( require (
github.com/analogj/go-util v0.0.0-20190301173314-5295e364eb14 github.com/analogj/go-util v0.0.0-20190301173314-5295e364eb14
@ -13,6 +13,7 @@ require (
github.com/influxdata/influxdb-client-go/v2 v2.9.0 github.com/influxdata/influxdb-client-go/v2 v2.9.0
github.com/jaypipes/ghw v0.6.1 github.com/jaypipes/ghw v0.6.1
github.com/mitchellh/mapstructure v1.2.2 github.com/mitchellh/mapstructure v1.2.2
github.com/samber/lo v1.25.0
github.com/sirupsen/logrus v1.4.2 github.com/sirupsen/logrus v1.4.2
github.com/spf13/viper v1.7.0 github.com/spf13/viper v1.7.0
github.com/stretchr/testify v1.7.1 github.com/stretchr/testify v1.7.1
@ -23,7 +24,6 @@ require (
require ( require (
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect
github.com/citilinkru/libudev v1.0.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deepmap/oapi-codegen v1.8.2 // indirect github.com/deepmap/oapi-codegen v1.8.2 // indirect
@ -68,6 +68,7 @@ require (
github.com/subosito/gotenv v1.2.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect
github.com/ugorji/go/codec v1.1.7 // indirect github.com/ugorji/go/codec v1.1.7 // indirect
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 // indirect golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 // indirect
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
@ -77,7 +78,7 @@ require (
gopkg.in/ini.v1 v1.55.0 // indirect gopkg.in/ini.v1 v1.55.0 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect gopkg.in/yaml.v2 v2.3.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
gosrc.io/xmpp v0.5.1 // indirect gosrc.io/xmpp v0.5.1 // indirect
howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect
modernc.org/libc v1.16.8 // indirect modernc.org/libc v1.16.8 // indirect

@ -39,8 +39,6 @@ github.com/chromedp/cdproto v0.0.0-20190812224334-39ef923dcb8d/go.mod h1:0YChpVz
github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0= github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM= github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM=
github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4= github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4=
github.com/citilinkru/libudev v1.0.0 h1:upErSdhsJGdiKxwxPmvcz43fwJJD9R+y1j8BqU4wHog=
github.com/citilinkru/libudev v1.0.0/go.mod h1:yaNdhdtfJMs5flqeXzUOMO0mT9QnyNh/U/jdY4WhA/I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/containrrr/shoutrrr v0.4.4 h1:vHZ4E/76pKVY+Jyn/qhBz3X540Bn8NI5ppPHK4PyILY= github.com/containrrr/shoutrrr v0.4.4 h1:vHZ4E/76pKVY+Jyn/qhBz3X540Bn8NI5ppPHK4PyILY=
@ -282,12 +280,11 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kvz/logstreamer v0.0.0-20201023134116-02d20f4338f5 h1:dkCjlgGN81ahDFtM9R1x16gFGTa7ZvgZfdtAfM9lWOs= github.com/kvz/logstreamer v0.0.0-20201023134116-02d20f4338f5 h1:dkCjlgGN81ahDFtM9R1x16gFGTa7ZvgZfdtAfM9lWOs=
github.com/kvz/logstreamer v0.0.0-20201023134116-02d20f4338f5/go.mod h1:8/LTPeDLaklcUjgSQBHbhBF1ibKAFxzS5o+H7USfMSA= github.com/kvz/logstreamer v0.0.0-20201023134116-02d20f4338f5/go.mod h1:8/LTPeDLaklcUjgSQBHbhBF1ibKAFxzS5o+H7USfMSA=
github.com/labstack/echo/v4 v4.2.1/go.mod h1:AA49e0DZ8kk5jTOOCKNuPR6oTnBS0dYiM4FW1e6jwpg= github.com/labstack/echo/v4 v4.2.1/go.mod h1:AA49e0DZ8kk5jTOOCKNuPR6oTnBS0dYiM4FW1e6jwpg=
@ -345,6 +342,7 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.6 h1:11TGpSHY7Esh/i/qnq02Jo5oVrI1Gue8Slbq0ujPZFQ= github.com/nxadm/tail v1.4.6 h1:11TGpSHY7Esh/i/qnq02Jo5oVrI1Gue8Slbq0ujPZFQ=
github.com/nxadm/tail v1.4.6/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.6/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
@ -388,6 +386,8 @@ github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThC
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/samber/lo v1.25.0 h1:H8F6cB0RotRdgcRCivTByAQePaYhGMdOTJIj2QFS2I0=
github.com/samber/lo v1.25.0/go.mod h1:2I7tgIv8Q1SG2xEIkRq0F2i2zgxVpnyPOP0d3Gj2r+A=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
@ -436,10 +436,10 @@ github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMT
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A= github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
@ -485,6 +485,8 @@ golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -656,8 +658,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
@ -675,8 +677,9 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.0.1 h1:omJoilUzyrAp0xNoio88lGJCroGdIOen9hq2A/+3ifw= gorm.io/driver/mysql v1.0.1 h1:omJoilUzyrAp0xNoio88lGJCroGdIOen9hq2A/+3ifw=
gorm.io/driver/mysql v1.0.1/go.mod h1:KtqSthtg55lFp3S5kUXqlGaelnWpKitn4k1xZTnoiPw= gorm.io/driver/mysql v1.0.1/go.mod h1:KtqSthtg55lFp3S5kUXqlGaelnWpKitn4k1xZTnoiPw=
gorm.io/driver/postgres v1.0.0 h1:Yh4jyFQ0a7F+JPU0Gtiam/eKmpT/XFc1FKxotGqc6FM= gorm.io/driver/postgres v1.0.0 h1:Yh4jyFQ0a7F+JPU0Gtiam/eKmpT/XFc1FKxotGqc6FM=

@ -242,21 +242,29 @@ func (sr *scrutinyRepository) EnsureBuckets(ctx context.Context, org *domain.Org
//create buckets (used for downsampling) //create buckets (used for downsampling)
weeklyBucket := fmt.Sprintf("%s_weekly", sr.appConfig.GetString("web.influxdb.bucket")) weeklyBucket := fmt.Sprintf("%s_weekly", sr.appConfig.GetString("web.influxdb.bucket"))
if _, foundErr := sr.influxClient.BucketsAPI().FindBucketByName(ctx, weeklyBucket); foundErr != nil { if foundWeeklyBucket, foundErr := sr.influxClient.BucketsAPI().FindBucketByName(ctx, weeklyBucket); foundErr != nil {
// metrics_weekly bucket will have a retention period of 8+1 weeks (since it will be down-sampled once a month) // metrics_weekly bucket will have a retention period of 8+1 weeks (since it will be down-sampled once a month)
_, err := sr.influxClient.BucketsAPI().CreateBucketWithName(ctx, org, weeklyBucket, weeklyBucketRetentionRule) _, err := sr.influxClient.BucketsAPI().CreateBucketWithName(ctx, org, weeklyBucket, weeklyBucketRetentionRule)
if err != nil { if err != nil {
return err return err
} }
} else if sr.appConfig.GetBool("web.influxdb.retention_policy") {
//correctly set the retention period for the bucket (may not be able to do it during setup/creation)
foundWeeklyBucket.RetentionRules = domain.RetentionRules{weeklyBucketRetentionRule}
sr.influxClient.BucketsAPI().UpdateBucket(ctx, foundWeeklyBucket)
} }
monthlyBucket := fmt.Sprintf("%s_monthly", sr.appConfig.GetString("web.influxdb.bucket")) monthlyBucket := fmt.Sprintf("%s_monthly", sr.appConfig.GetString("web.influxdb.bucket"))
if _, foundErr := sr.influxClient.BucketsAPI().FindBucketByName(ctx, monthlyBucket); foundErr != nil { if foundMonthlyBucket, foundErr := sr.influxClient.BucketsAPI().FindBucketByName(ctx, monthlyBucket); foundErr != nil {
// metrics_monthly bucket will have a retention period of 24+1 months (since it will be down-sampled once a year) // metrics_monthly bucket will have a retention period of 24+1 months (since it will be down-sampled once a year)
_, err := sr.influxClient.BucketsAPI().CreateBucketWithName(ctx, org, monthlyBucket, monthlyBucketRetentionRule) _, err := sr.influxClient.BucketsAPI().CreateBucketWithName(ctx, org, monthlyBucket, monthlyBucketRetentionRule)
if err != nil { if err != nil {
return err return err
} }
} else if sr.appConfig.GetBool("web.influxdb.retention_policy") {
//correctly set the retention period for the bucket (may not be able to do it during setup/creation)
foundMonthlyBucket.RetentionRules = domain.RetentionRules{monthlyBucketRetentionRule}
sr.influxClient.BucketsAPI().UpdateBucket(ctx, foundMonthlyBucket)
} }
yearlyBucket := fmt.Sprintf("%s_yearly", sr.appConfig.GetString("web.influxdb.bucket")) yearlyBucket := fmt.Sprintf("%s_yearly", sr.appConfig.GetString("web.influxdb.bucket"))

@ -267,6 +267,14 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
return tx.AutoMigrate(m20220509170100.Device{}) return tx.AutoMigrate(m20220509170100.Device{})
}, },
}, },
{
ID: "m20220709181300",
Migrate: func(tx *gorm.DB) error {
// delete devices with empty `wwn` field (they are impossible to delete manually), and are invalid.
return tx.Where("wwn = ?", "").Delete(&models.Device{}).Error
},
},
}) })
if err := m.Migrate(); err != nil { if err := m.Migrate(); err != nil {

@ -11,35 +11,71 @@ import (
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (sr *scrutinyRepository) EnsureTasks(ctx context.Context, orgID string) error { func (sr *scrutinyRepository) EnsureTasks(ctx context.Context, orgID string) error {
weeklyTaskName := "tsk-weekly-aggr" weeklyTaskName := "tsk-weekly-aggr"
weeklyTaskScript := sr.DownsampleScript("weekly", weeklyTaskName, "0 1 * * 0")
if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: weeklyTaskName}); findErr == nil && len(found) == 0 { if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: weeklyTaskName}); findErr == nil && len(found) == 0 {
//weekly on Sunday at 1:00am //weekly on Sunday at 1:00am
_, err := sr.influxTaskApi.CreateTaskWithCron(ctx, weeklyTaskName, sr.DownsampleScript("weekly"), "0 1 * * 0", orgID) _, err := sr.influxTaskApi.CreateTaskByFlux(ctx, weeklyTaskScript, orgID)
if err != nil {
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 { if err != nil {
return err return err
} }
} }
}
monthlyTaskName := "tsk-monthly-aggr" monthlyTaskName := "tsk-monthly-aggr"
monthlyTaskScript := sr.DownsampleScript("monthly", monthlyTaskName, "30 1 1 * *")
if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: monthlyTaskName}); findErr == nil && len(found) == 0 { if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: monthlyTaskName}); findErr == nil && len(found) == 0 {
//monthly on first day of the month at 1:30am //monthly on first day of the month at 1:30am
_, err := sr.influxTaskApi.CreateTaskWithCron(ctx, monthlyTaskName, sr.DownsampleScript("monthly"), "30 1 1 * *", orgID) _, err := sr.influxTaskApi.CreateTaskByFlux(ctx, monthlyTaskScript, orgID)
if err != nil {
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 { if err != nil {
return err return err
} }
} }
}
yearlyTaskName := "tsk-yearly-aggr" yearlyTaskName := "tsk-yearly-aggr"
yearlyTaskScript := sr.DownsampleScript("yearly", yearlyTaskName, "0 2 1 1 *")
if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: yearlyTaskName}); findErr == nil && len(found) == 0 { if found, findErr := sr.influxTaskApi.FindTasks(ctx, &api.TaskFilter{Name: yearlyTaskName}); findErr == nil && len(found) == 0 {
//yearly on the first day of the year at 2:00am //yearly on the first day of the year at 2:00am
_, err := sr.influxTaskApi.CreateTaskWithCron(ctx, yearlyTaskName, sr.DownsampleScript("yearly"), "0 2 1 1 *", orgID) _, err := sr.influxTaskApi.CreateTaskByFlux(ctx, yearlyTaskScript, orgID)
if err != nil {
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 { if err != nil {
return err return err
} }
} }
}
return nil return nil
} }
func (sr *scrutinyRepository) DownsampleScript(aggregationType string) string { func (sr *scrutinyRepository) DownsampleScript(aggregationType string, name string, cron string) string {
var sourceBucket string // the source of the data var sourceBucket string // the source of the data
var destBucket string // the destination for the aggregated data var destBucket string // the destination for the aggregated data
var rangeStart string var rangeStart string
@ -88,30 +124,37 @@ func (sr *scrutinyRepository) DownsampleScript(aggregationType string) string {
*/ */
return fmt.Sprintf(` return fmt.Sprintf(`
sourceBucket = "%s" option task = {
rangeStart = %s name: "%s",
rangeEnd = %s cron: "%s",
aggWindow = %s }
destBucket = "%s"
destOrg = "%s"
from(bucket: sourceBucket) sourceBucket = "%s"
|> range(start: rangeStart, stop: rangeEnd) rangeStart = %s
|> filter(fn: (r) => r["_measurement"] == "smart" ) rangeEnd = %s
|> group(columns: ["device_wwn", "_field"]) aggWindow = %s
|> aggregateWindow(every: aggWindow, fn: last, createEmpty: false) destBucket = "%s"
|> to(bucket: destBucket, org: destOrg) destOrg = "%s"
temp_data = from(bucket: sourceBucket) from(bucket: sourceBucket)
|> range(start: rangeStart, stop: rangeEnd) |> range(start: rangeStart, stop: rangeEnd)
|> filter(fn: (r) => r["_measurement"] == "temp") |> filter(fn: (r) => r["_measurement"] == "smart" )
|> group(columns: ["device_wwn"]) |> group(columns: ["device_wwn", "_field"])
|> toInt() |> aggregateWindow(every: aggWindow, fn: last, createEmpty: false)
|> to(bucket: destBucket, org: destOrg)
temp_data from(bucket: sourceBucket)
|> aggregateWindow(fn: mean, every: aggWindow, createEmpty: false) |> range(start: rangeStart, stop: rangeEnd)
|> to(bucket: destBucket, org: destOrg) |> filter(fn: (r) => r["_measurement"] == "temp")
|> group(columns: ["device_wwn"])
|> toInt()
|> aggregateWindow(fn: mean, every: aggWindow, createEmpty: false)
|> set(key: "_measurement", value: "temp")
|> set(key: "_field", value: "temp")
|> to(bucket: destBucket, org: destOrg)
`, `,
name,
cron,
sourceBucket, sourceBucket,
rangeStart, rangeStart,
rangeEnd, rangeEnd,

@ -0,0 +1,164 @@
package database
import (
mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
"testing"
)
func Test_DownsampleScript_Weekly(t *testing.T) {
t.Parallel()
//setup
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl)
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
deviceRepo := scrutinyRepository{
appConfig: fakeConfig,
}
aggregationType := "weekly"
//test
influxDbScript := deviceRepo.DownsampleScript(aggregationType, "tsk-weekly-aggr", "0 1 * * 0")
//assert
require.Equal(t, `
option task = {
name: "tsk-weekly-aggr",
cron: "0 1 * * 0",
}
sourceBucket = "metrics"
rangeStart = -2w
rangeEnd = -1w
aggWindow = 1w
destBucket = "metrics_weekly"
destOrg = "scrutiny"
from(bucket: sourceBucket)
|> range(start: rangeStart, stop: rangeEnd)
|> filter(fn: (r) => r["_measurement"] == "smart" )
|> group(columns: ["device_wwn", "_field"])
|> aggregateWindow(every: aggWindow, fn: last, createEmpty: false)
|> to(bucket: destBucket, org: destOrg)
from(bucket: sourceBucket)
|> range(start: rangeStart, stop: rangeEnd)
|> filter(fn: (r) => r["_measurement"] == "temp")
|> group(columns: ["device_wwn"])
|> toInt()
|> aggregateWindow(fn: mean, every: aggWindow, createEmpty: false)
|> set(key: "_measurement", value: "temp")
|> set(key: "_field", value: "temp")
|> to(bucket: destBucket, org: destOrg)
`, influxDbScript)
}
func Test_DownsampleScript_Monthly(t *testing.T) {
t.Parallel()
//setup
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl)
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
deviceRepo := scrutinyRepository{
appConfig: fakeConfig,
}
aggregationType := "monthly"
//test
influxDbScript := deviceRepo.DownsampleScript(aggregationType, "tsk-monthly-aggr", "30 1 1 * *")
//assert
require.Equal(t, `
option task = {
name: "tsk-monthly-aggr",
cron: "30 1 1 * *",
}
sourceBucket = "metrics_weekly"
rangeStart = -2mo
rangeEnd = -1mo
aggWindow = 1mo
destBucket = "metrics_monthly"
destOrg = "scrutiny"
from(bucket: sourceBucket)
|> range(start: rangeStart, stop: rangeEnd)
|> filter(fn: (r) => r["_measurement"] == "smart" )
|> group(columns: ["device_wwn", "_field"])
|> aggregateWindow(every: aggWindow, fn: last, createEmpty: false)
|> to(bucket: destBucket, org: destOrg)
from(bucket: sourceBucket)
|> range(start: rangeStart, stop: rangeEnd)
|> filter(fn: (r) => r["_measurement"] == "temp")
|> group(columns: ["device_wwn"])
|> toInt()
|> aggregateWindow(fn: mean, every: aggWindow, createEmpty: false)
|> set(key: "_measurement", value: "temp")
|> set(key: "_field", value: "temp")
|> to(bucket: destBucket, org: destOrg)
`, influxDbScript)
}
func Test_DownsampleScript_Yearly(t *testing.T) {
t.Parallel()
//setup
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl)
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
deviceRepo := scrutinyRepository{
appConfig: fakeConfig,
}
aggregationType := "yearly"
//test
influxDbScript := deviceRepo.DownsampleScript(aggregationType, "tsk-yearly-aggr", "0 2 1 1 *")
//assert
require.Equal(t, `
option task = {
name: "tsk-yearly-aggr",
cron: "0 2 1 1 *",
}
sourceBucket = "metrics_monthly"
rangeStart = -2y
rangeEnd = -1y
aggWindow = 1y
destBucket = "metrics_yearly"
destOrg = "scrutiny"
from(bucket: sourceBucket)
|> range(start: rangeStart, stop: rangeEnd)
|> filter(fn: (r) => r["_measurement"] == "smart" )
|> group(columns: ["device_wwn", "_field"])
|> aggregateWindow(every: aggWindow, fn: last, createEmpty: false)
|> to(bucket: destBucket, org: destOrg)
from(bucket: sourceBucket)
|> range(start: rangeStart, stop: rangeEnd)
|> filter(fn: (r) => r["_measurement"] == "temp")
|> group(columns: ["device_wwn"])
|> toInt()
|> aggregateWindow(fn: mean, every: aggWindow, createEmpty: false)
|> set(key: "_measurement", value: "temp")
|> set(key: "_field", value: "temp")
|> to(bucket: destBucket, org: destOrg)
`, influxDbScript)
}

@ -0,0 +1,185 @@
package database
import (
mock_config "github.com/analogj/scrutiny/webapp/backend/pkg/config/mock"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
"testing"
)
func Test_aggregateTempQuery_Week(t *testing.T) {
t.Parallel()
//setup
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl)
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
deviceRepo := scrutinyRepository{
appConfig: fakeConfig,
}
aggregationType := DURATION_KEY_WEEK
//test
influxDbScript := deviceRepo.aggregateTempQuery(aggregationType)
//assert
require.Equal(t, `import "influxdata/influxdb/schema"
weekData = from(bucket: "metrics")
|> range(start: -1w, stop: now())
|> filter(fn: (r) => r["_measurement"] == "temp" )
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> group(columns: ["device_wwn"])
|> toInt()
weekData
|> schema.fieldsAsCols()
|> yield()`, influxDbScript)
}
func Test_aggregateTempQuery_Month(t *testing.T) {
t.Parallel()
//setup
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl)
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
deviceRepo := scrutinyRepository{
appConfig: fakeConfig,
}
aggregationType := DURATION_KEY_MONTH
//test
influxDbScript := deviceRepo.aggregateTempQuery(aggregationType)
//assert
require.Equal(t, `import "influxdata/influxdb/schema"
weekData = from(bucket: "metrics")
|> range(start: -1w, stop: now())
|> filter(fn: (r) => r["_measurement"] == "temp" )
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> group(columns: ["device_wwn"])
|> toInt()
monthData = from(bucket: "metrics_weekly")
|> range(start: -1mo, stop: -1w)
|> filter(fn: (r) => r["_measurement"] == "temp" )
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> group(columns: ["device_wwn"])
|> toInt()
union(tables: [weekData, monthData])
|> group(columns: ["device_wwn"])
|> sort(columns: ["_time"], desc: false)
|> schema.fieldsAsCols()`, influxDbScript)
}
func Test_aggregateTempQuery_Year(t *testing.T) {
t.Parallel()
//setup
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl)
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
deviceRepo := scrutinyRepository{
appConfig: fakeConfig,
}
aggregationType := DURATION_KEY_YEAR
//test
influxDbScript := deviceRepo.aggregateTempQuery(aggregationType)
//assert
require.Equal(t, `import "influxdata/influxdb/schema"
weekData = from(bucket: "metrics")
|> range(start: -1w, stop: now())
|> filter(fn: (r) => r["_measurement"] == "temp" )
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> group(columns: ["device_wwn"])
|> toInt()
monthData = from(bucket: "metrics_weekly")
|> range(start: -1mo, stop: -1w)
|> filter(fn: (r) => r["_measurement"] == "temp" )
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> group(columns: ["device_wwn"])
|> toInt()
yearData = from(bucket: "metrics_monthly")
|> range(start: -1y, stop: -1mo)
|> filter(fn: (r) => r["_measurement"] == "temp" )
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> group(columns: ["device_wwn"])
|> toInt()
union(tables: [weekData, monthData, yearData])
|> group(columns: ["device_wwn"])
|> sort(columns: ["_time"], desc: false)
|> schema.fieldsAsCols()`, influxDbScript)
}
func Test_aggregateTempQuery_Forever(t *testing.T) {
t.Parallel()
//setup
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl)
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
deviceRepo := scrutinyRepository{
appConfig: fakeConfig,
}
aggregationType := DURATION_KEY_FOREVER
//test
influxDbScript := deviceRepo.aggregateTempQuery(aggregationType)
//assert
require.Equal(t, `import "influxdata/influxdb/schema"
weekData = from(bucket: "metrics")
|> range(start: -1w, stop: now())
|> filter(fn: (r) => r["_measurement"] == "temp" )
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> group(columns: ["device_wwn"])
|> toInt()
monthData = from(bucket: "metrics_weekly")
|> range(start: -1mo, stop: -1w)
|> filter(fn: (r) => r["_measurement"] == "temp" )
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> group(columns: ["device_wwn"])
|> toInt()
yearData = from(bucket: "metrics_monthly")
|> range(start: -1y, stop: -1mo)
|> filter(fn: (r) => r["_measurement"] == "temp" )
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> group(columns: ["device_wwn"])
|> toInt()
foreverData = from(bucket: "metrics_yearly")
|> range(start: -10y, stop: -1y)
|> filter(fn: (r) => r["_measurement"] == "temp" )
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> group(columns: ["device_wwn"])
|> toInt()
union(tables: [weekData, monthData, yearData, foreverData])
|> group(columns: ["device_wwn"])
|> sort(columns: ["_time"], desc: false)
|> schema.fieldsAsCols()`, influxDbScript)
}

@ -18,6 +18,7 @@ func GetDevicesSummary(c *gin.Context) {
return return
} }
//this must match DeviceSummaryWrapper (webapp/backend/pkg/models/device_summary.go)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": map[string]interface{}{ "data": map[string]interface{}{

@ -4,6 +4,7 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg/database" "github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/analogj/scrutiny/webapp/backend/pkg/models" "github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/samber/lo"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"net/http" "net/http"
) )
@ -22,8 +23,13 @@ func RegisterDevices(c *gin.Context) {
return return
} }
//filter any device with empty wwn (they are invalid)
detectedStorageDevices := lo.Filter[models.Device](collectorDeviceWrapper.Data, func(dev models.Device, _ int) bool {
return len(dev.WWN) > 0
})
errs := []error{} errs := []error{}
for _, dev := range collectorDeviceWrapper.Data { for _, dev := range detectedStorageDevices {
//insert devices into DB (and update specified columns if device is already registered) //insert devices into DB (and update specified columns if device is already registered)
// update device fields that may change: (DeviceType, HostID) // update device fields that may change: (DeviceType, HostID)
if err := deviceRepo.RegisterDevice(c, dev); err != nil { if err := deviceRepo.RegisterDevice(c, dev); err != nil {
@ -40,7 +46,7 @@ func RegisterDevices(c *gin.Context) {
} else { } else {
c.JSON(http.StatusOK, models.DeviceWrapper{ c.JSON(http.StatusOK, models.DeviceWrapper{
Success: true, Success: true,
Data: collectorDeviceWrapper.Data, Data: detectedStorageDevices,
}) })
return return
} }

@ -20,6 +20,10 @@ func UploadDeviceMetrics(c *gin.Context) {
//appConfig := c.MustGet("CONFIG").(config.Interface) //appConfig := c.MustGet("CONFIG").(config.Interface)
if c.Param("wwn") == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false})
}
var collectorSmartData collector.SmartInfo var collectorSmartData collector.SmartInfo
err := c.BindJSON(&collectorSmartData) err := c.BindJSON(&collectorSmartData)
if err != nil { if err != nil {

@ -46,3 +46,5 @@ testem.log
Thumbs.db Thumbs.db
/dist /dist
/coverage

@ -91,6 +91,7 @@
}, },
"test": { "test": {
"builder": "@angular-devkit/build-angular:karma", "builder": "@angular-devkit/build-angular:karma",
"defaultConfiguration": "production",
"options": { "options": {
"main": "src/test.ts", "main": "src/test.ts",
"polyfills": "src/polyfills.ts", "polyfills": "src/polyfills.ts",
@ -101,10 +102,22 @@
"src/favicon-32x32.png", "src/favicon-32x32.png",
"src/assets" "src/assets"
], ],
"stylePreprocessorOptions": {
"includePaths": [
"src/@treo/styles"
]
},
"styles": [ "styles": [
"src/styles.scss" "src/styles/vendors.scss",
"src/@treo/styles/main.scss",
"src/styles/styles.scss",
"src/styles/tailwind.scss"
], ],
"scripts": [] "scripts": [],
"fileReplacements": [{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}]
} }
}, },
"lint": { "lint": {

@ -17,8 +17,8 @@ module.exports = function (config)
clearContext: false // leave Jasmine Spec Runner output visible in browser clearContext: false // leave Jasmine Spec Runner output visible in browser
}, },
coverageIstanbulReporter: { coverageIstanbulReporter: {
dir : require('path').join(__dirname, './coverage/treo'), dir: require('path').join(__dirname, './coverage'),
reports : ['html', 'lcovonly', 'text-summary'], reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true fixWebpackSourcePaths: true
}, },
reporters : ['progress', 'kjhtml'], reporters : ['progress', 'kjhtml'],

@ -1,22 +1,28 @@
import { Layout } from 'app/layout/layout.types'; import {Layout} from 'app/layout/layout.types';
// Theme type // Theme type
export type Theme = 'light' | 'dark' | 'system'; export type Theme = 'light' | 'dark' | 'system';
// Device title to display on the dashboard
export type DashboardDisplay = 'name' | 'serial_id' | 'uuid' | 'label'
export type DashboardSort = 'status' | 'title' | 'age'
export type TemperatureUnit = 'celsius' | 'fahrenheit'
/** /**
* AppConfig interface. Update this interface to strictly type your config * AppConfig interface. Update this interface to strictly type your config
* object. * object.
*/ */
export interface AppConfig export interface AppConfig {
{
theme: Theme; theme: Theme;
layout: Layout; layout: Layout;
// Dashboard options // Dashboard options
dashboardDisplay: string; dashboardDisplay: DashboardDisplay;
dashboardSort: string; dashboardSort: DashboardSort;
temperatureUnit: string; temperatureUnit: TemperatureUnit;
} }
/** /**

@ -0,0 +1,14 @@
import {DeviceModel} from 'app/core/models/device-model';
import {SmartModel} from 'app/core/models/measurements/smart-model';
import {AttributeMetadataModel} from 'app/core/models/thresholds/attribute-metadata-model';
// maps to webapp/backend/pkg/models/device_summary.go
export interface DeviceDetailsResponseWrapper {
success: boolean;
errors?: any[];
data: {
device: DeviceModel;
smart_results: SmartModel[];
},
metadata: { [key: string]: AttributeMetadataModel } | { [key: number]: AttributeMetadataModel };
}

@ -0,0 +1,26 @@
// maps to webapp/backend/pkg/models/device.go
export interface DeviceModel {
wwn: string;
device_name?: string;
device_uuid?: string;
device_serial_id?: string;
device_label?: string;
manufacturer: string;
model_name: string;
interface_type: string;
interface_speed: string;
serial_number: string;
firmware: string;
rotational_speed: number;
capacity: number;
form_factor: string;
smart_support: boolean;
device_protocol: string;
device_type: string;
label: string;
host_id: string;
device_status: number;
}

@ -0,0 +1,16 @@
import {DeviceModel} from 'app/core/models/device-model';
import {SmartTemperatureModel} from 'app/core/models/measurements/smart-temperature-model';
// maps to webapp/backend/pkg/models/device_summary.go
export interface DeviceSummaryModel {
device: DeviceModel;
smart?: SmartSummary;
temp_history?: SmartTemperatureModel[];
}
export interface SmartSummary {
collector_date?: string,
temp?: number
power_on_hours?: number
}

@ -0,0 +1,10 @@
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
// maps to webapp/backend/pkg/models/device_summary.go
export interface DeviceSummaryResponseWrapper {
success: boolean;
errors: any[];
data: {
summary: { [key: string]: DeviceSummaryModel }
}
}

@ -0,0 +1,9 @@
import {SmartTemperatureModel} from './measurements/smart-temperature-model';
export interface DeviceSummaryTempResponseWrapper {
success: boolean;
errors: any[];
data: {
temp_history: { [key: string]: SmartTemperatureModel[]; }
}
}

@ -0,0 +1,19 @@
// maps to webapp/backend/pkg/models/measurements/smart_ata_attribute.go
// maps to webapp/backend/pkg/models/measurements/smart_nvme_attribute.go
// maps to webapp/backend/pkg/models/measurements/smart_scsi_attribute.go
export interface SmartAttributeModel {
attribute_id: number | string
value: number
thresh: number
worst?: number
raw_value?: number
raw_string?: string
when_failed?: string
transformed_value: number
status: number
status_reason?: string
failure_rate?: number
chartData?: any[]
}

@ -0,0 +1,13 @@
// maps to webapp/backend/pkg/models/measurements/smart.go
import {SmartAttributeModel} from './smart-attribute-model';
export interface SmartModel {
date: string;
device_wwn: string;
device_protocol: string;
temp: number;
power_on_hours: number;
power_cycle_count: number
attrs: { [key: string]: SmartAttributeModel }
}

@ -0,0 +1,6 @@
// maps to webapp/backend/pkg/models/measurements/smart_temperature.go
export interface SmartTemperatureModel {
date: string;
temp: number;
}

@ -0,0 +1,13 @@
// map to webapp/backend/pkg/thresholds/ata_attribute_metadata.go
// map to webapp/backend/pkg/thresholds/nvme_attribute_metadata.go
// map to webapp/backend/pkg/thresholds/scsi_attribute_metadata.go
export interface AttributeMetadataModel {
display_name: string
ideal: string
critical: boolean
description: string
transform_value_unit?: string
observed_thresholds?: any[]
display_type: string
}

File diff suppressed because it is too large Load Diff

@ -1,16 +1,39 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {DashboardDeviceDeleteDialogComponent} from './dashboard-device-delete-dialog.component';
import {HttpClientModule} from '@angular/common/http';
import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from '@angular/material/dialog';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {SharedModule} from '../../../shared/shared.module';
import {DashboardDeviceDeleteDialogService} from './dashboard-device-delete-dialog.service';
import {of} from 'rxjs';
import { DashboardDeviceDeleteDialogComponent } from './dashboard-device-delete-dialog.component';
describe('DashboardDeviceDeleteDialogComponent', () => { describe('DashboardDeviceDeleteDialogComponent', () => {
let component: DashboardDeviceDeleteDialogComponent; let component: DashboardDeviceDeleteDialogComponent;
let fixture: ComponentFixture<DashboardDeviceDeleteDialogComponent>; let fixture: ComponentFixture<DashboardDeviceDeleteDialogComponent>;
const matDialogRefSpy = jasmine.createSpyObj('MatDialogRef', ['closeDialog', 'close']);
const dashboardDeviceDeleteDialogServiceSpy = jasmine.createSpyObj('DashboardDeviceDeleteDialogService', ['deleteDevice']);
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ DashboardDeviceDeleteDialogComponent ] 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(); .compileComponents()
})); }));
beforeEach(() => { beforeEach(() => {
@ -22,4 +45,20 @@ describe('DashboardDeviceDeleteDialogComponent', () => {
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it('should close the component if cancel is clicked', () => {
matDialogRefSpy.closeDialog.calls.reset();
matDialogRefSpy.closeDialog()
expect(matDialogRefSpy.closeDialog).toHaveBeenCalled();
});
it('should attempt to delete device if delete is clicked', () => {
dashboardDeviceDeleteDialogServiceSpy.deleteDevice.and.returnValue(of({'success': true}));
component.onDeleteClick()
expect(dashboardDeviceDeleteDialogServiceSpy.deleteDevice).toHaveBeenCalledWith('test-wwn');
expect(dashboardDeviceDeleteDialogServiceSpy.deleteDevice.calls.count())
.withContext('one call')
.toBe(1);
});
}); });

@ -1,7 +1,6 @@
import { Component, OnInit, Inject } from '@angular/core'; import {Component, Inject, OnInit} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
import {DashboardDeviceDeleteDialogService} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.service'; import {DashboardDeviceDeleteDialogService} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.service';
import {Subject} from 'rxjs';
@Component({ @Component({
selector: 'app-dashboard-device-delete-dialog', selector: 'app-dashboard-device-delete-dialog',

@ -1,44 +1,21 @@
import { NgModule } from '@angular/core'; import {NgModule} from '@angular/core';
import { RouterModule } from '@angular/router'; import {RouterModule} from '@angular/router';
import { Overlay } from '@angular/cdk/overlay'; import {MatButtonModule} from '@angular/material/button';
import { MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocompleteModule } from '@angular/material/autocomplete'; import {MatIconModule} from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import {SharedModule} from 'app/shared/shared.module';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { SharedModule } from 'app/shared/shared.module';
import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component' import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component'
import { MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatTabsModule} from '@angular/material/tabs';
import {MatSliderModule} from '@angular/material/slider';
import {MatSlideToggleModule} from '@angular/material/slide-toggle';
import {MatTooltipModule} from '@angular/material/tooltip';
import {dashboardRoutes} from 'app/modules/dashboard/dashboard.routing'; import {dashboardRoutes} from 'app/modules/dashboard/dashboard.routing';
import {MatDividerModule} from '@angular/material/divider'; import {MatDialogModule} from '@angular/material/dialog';
import {MatMenuModule} from '@angular/material/menu';
import {MatProgressBarModule} from '@angular/material/progress-bar';
import {MatSortModule} from '@angular/material/sort';
import {MatTableModule} from '@angular/material/table';
import {NgApexchartsModule} from 'ng-apexcharts';
import { MatDialogModule } from '@angular/material/dialog';
@NgModule({ @NgModule({
declarations: [ declarations: [
DashboardDeviceDeleteDialogComponent DashboardDeviceDeleteDialogComponent
], ],
imports : [ imports: [
RouterModule.forChild([]), RouterModule.forChild([]),
RouterModule.forChild(dashboardRoutes), RouterModule.forChild(dashboardRoutes),
MatButtonModule, MatButtonModule,
MatDividerModule,
MatTooltipModule,
MatIconModule, MatIconModule,
MatMenuModule,
MatProgressBarModule,
MatSortModule,
MatTableModule,
NgApexchartsModule,
SharedModule, SharedModule,
MatDialogModule MatDialogModule
], ],

@ -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', () => { describe('DashboardDeviceComponent', () => {
let component: DashboardDeviceComponent; let component: DashboardDeviceComponent;
let fixture: ComponentFixture<DashboardDeviceComponent>; let fixture: ComponentFixture<DashboardDeviceComponent>;
const matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']);
// const configServiceSpy = jasmine.createSpyObj('TreoConfigService', ['config$']);
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ DashboardDeviceComponent ] imports: [
MatButtonModule,
MatIconModule,
MatMenuModule,
SharedModule,
],
providers: [
{provide: MatDialog, useValue: matDialogSpy},
{provide: TREO_APP_CONFIG, useValue: {dashboardDisplay: 'name'}}
],
declarations: [DashboardDeviceComponent]
}) })
.compileComponents(); .compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {
// configServiceSpy.config$.and.returnValue(of({'success': true}));
fixture = TestBed.createComponent(DashboardDeviceComponent); fixture = TestBed.createComponent(DashboardDeviceComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges();
}); });
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); 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')
});
})
}); });

@ -1,4 +1,4 @@
import { Component, Input, Output, OnInit, EventEmitter} from '@angular/core'; import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import * as moment from 'moment'; import * as moment from 'moment';
import {takeUntil} from 'rxjs/operators'; import {takeUntil} from 'rxjs/operators';
import {AppConfig} from 'app/core/config/app.config'; import {AppConfig} from 'app/core/config/app.config';
@ -8,6 +8,7 @@ import humanizeDuration from 'humanize-duration'
import {MatDialog} from '@angular/material/dialog'; import {MatDialog} from '@angular/material/dialog';
import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component'; import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component';
import {DeviceTitlePipe} from 'app/shared/device-title.pipe'; import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
@Component({ @Component({
selector: 'app-dashboard-device', selector: 'app-dashboard-device',
@ -23,7 +24,8 @@ export class DashboardDeviceComponent implements OnInit {
// Set the private defaults // Set the private defaults
this._unsubscribeAll = new Subject(); this._unsubscribeAll = new Subject();
} }
@Input() deviceSummary: any;
@Input() deviceSummary: DeviceSummaryModel;
@Input() deviceWWN: string; @Input() deviceWWN: string;
@Output() deviceDeleted = new EventEmitter<string>(); @Output() deviceDeleted = new EventEmitter<string>();
@ -47,28 +49,27 @@ export class DashboardDeviceComponent implements OnInit {
// @ Public methods // @ Public methods
// ----------------------------------------------------------------------------------------------------- // -----------------------------------------------------------------------------------------------------
classDeviceLastUpdatedOn(deviceSummary): string { classDeviceLastUpdatedOn(deviceSummary: DeviceSummaryModel): string {
if (deviceSummary.device.device_status !== 0) { if (deviceSummary.device.device_status !== 0) {
return 'text-red' // if the device has failed, always highlight in red return 'text-red' // if the device has failed, always highlight in red
} else if(deviceSummary.device.device_status === 0 && deviceSummary.smart){ } else if (deviceSummary.device.device_status === 0 && deviceSummary.smart) {
if(moment().subtract(14, 'd').isBefore(deviceSummary.smart.collector_date)){ if (moment().subtract(14, 'days').isBefore(deviceSummary.smart.collector_date)) {
// this device was updated in the last 2 weeks. // this device was updated in the last 2 weeks.
return 'text-green' return 'text-green'
} else if(moment().subtract(1, 'm').isBefore(deviceSummary.smart.collector_date)){ } else if (moment().subtract(1, 'months').isBefore(deviceSummary.smart.collector_date)) {
// this device was updated in the last month // this device was updated in the last month
return 'text-yellow' return 'text-yellow'
} else{ } else {
// last updated more than a month ago. // last updated more than a month ago.
return 'text-red' return 'text-red'
} }
} else { } else {
return '' return ''
} }
} }
deviceStatusString(deviceStatus): string { deviceStatusString(deviceStatus: number): string {
if(deviceStatus === 0){ if (deviceStatus === 0) {
return 'passed' return 'passed'
} else { } else {
return 'failed' return 'failed'
@ -76,16 +77,18 @@ export class DashboardDeviceComponent implements OnInit {
} }
openDeleteDialog(): void { openDeleteDialog(): void {
const dialogRef = this.dialog.open(DashboardDeviceDeleteDialogComponent, { const dialogRef = this.dialog.open(DashboardDeviceDeleteDialogComponent, {
// width: '250px', // width: '250px',
data: {wwn: this.deviceWWN, title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboardDisplay)} data: {
wwn: this.deviceWWN,
title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboardDisplay)
}
}); });
dialogRef.afterClosed().subscribe(result => { dialogRef.afterClosed().subscribe(result => {
console.log('The dialog was closed', result); console.log('The dialog was closed', result);
if(result.success){ if (result.success) {
this.deviceDeleted.emit(this.deviceWWN) this.deviceDeleted.emit(this.deviceWWN)
} }
}); });

@ -1,53 +1,30 @@
import { NgModule } from '@angular/core'; import {NgModule} from '@angular/core';
import { RouterModule } from '@angular/router'; import {RouterModule} from '@angular/router';
import { Overlay } from '@angular/cdk/overlay'; import {MatButtonModule} from '@angular/material/button';
import { MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocompleteModule } from '@angular/material/autocomplete'; import {MatIconModule} from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import {SharedModule} from 'app/shared/shared.module';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { SharedModule } from 'app/shared/shared.module';
import {DashboardDeviceComponent} from 'app/layout/common/dashboard-device/dashboard-device.component' import {DashboardDeviceComponent} from 'app/layout/common/dashboard-device/dashboard-device.component'
import { MatDialogModule } from '@angular/material/dialog';
import { MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatTabsModule} from '@angular/material/tabs';
import {MatSliderModule} from '@angular/material/slider';
import {MatSlideToggleModule} from '@angular/material/slide-toggle';
import {MatTooltipModule} from '@angular/material/tooltip';
import {dashboardRoutes} from '../../../modules/dashboard/dashboard.routing'; import {dashboardRoutes} from '../../../modules/dashboard/dashboard.routing';
import {MatDividerModule} from '@angular/material/divider';
import {MatMenuModule} from '@angular/material/menu'; import {MatMenuModule} from '@angular/material/menu';
import {MatProgressBarModule} from '@angular/material/progress-bar';
import {MatSortModule} from '@angular/material/sort';
import {MatTableModule} from '@angular/material/table';
import {NgApexchartsModule} from 'ng-apexcharts';
import {DashboardDeviceDeleteDialogModule} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.module'; import {DashboardDeviceDeleteDialogModule} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.module';
@NgModule({ @NgModule({
declarations: [ declarations: [
DashboardDeviceComponent DashboardDeviceComponent
], ],
imports : [ imports: [
RouterModule.forChild([]), RouterModule.forChild([]),
RouterModule.forChild(dashboardRoutes), RouterModule.forChild(dashboardRoutes),
MatButtonModule, MatButtonModule,
MatDividerModule,
MatTooltipModule,
MatIconModule, MatIconModule,
MatMenuModule, MatMenuModule,
MatProgressBarModule,
MatSortModule,
MatTableModule,
NgApexchartsModule,
SharedModule, SharedModule,
DashboardDeviceDeleteDialogModule DashboardDeviceDeleteDialogModule
], ],
exports : [ exports: [
DashboardDeviceComponent, DashboardDeviceComponent,
], ],
providers : [] providers: []
}) })
export class DashboardDeviceModule export class DashboardDeviceModule {
{
} }

@ -1,25 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardSettingsComponent } from './dashboard-settings.component';
describe('DashboardSettingsComponent', () => {
let component: DashboardSettingsComponent;
let fixture: ComponentFixture<DashboardSettingsComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ DashboardSettingsComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DashboardSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,6 +1,6 @@
import { Component, OnInit } from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {AppConfig} from 'app/core/config/app.config'; 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 {Subject} from 'rxjs';
import {takeUntil} from 'rxjs/operators'; import {takeUntil} from 'rxjs/operators';
@ -43,8 +43,6 @@ export class DashboardSettingsComponent implements OnInit {
} }
saveSettings(): void { saveSettings(): void {
const newSettings = { const newSettings = {
dashboardDisplay: this.dashboardDisplay, dashboardDisplay: this.dashboardDisplay,
dashboardSort: this.dashboardSort, dashboardSort: this.dashboardSort,

@ -1,16 +1,15 @@
import { NgModule } from '@angular/core'; import {NgModule} from '@angular/core';
import { RouterModule } from '@angular/router'; import {RouterModule} from '@angular/router';
import { Overlay } from '@angular/cdk/overlay'; import {MatAutocompleteModule} from '@angular/material/autocomplete';
import { MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocompleteModule } from '@angular/material/autocomplete'; import {MatButtonModule} from '@angular/material/button';
import { MatButtonModule } from '@angular/material/button'; import {MatSelectModule} from '@angular/material/select';
import { MatSelectModule } from '@angular/material/select'; import {MatFormFieldModule} from '@angular/material/form-field';
import { MatFormFieldModule } from '@angular/material/form-field'; import {MatIconModule} from '@angular/material/icon';
import { MatIconModule } from '@angular/material/icon'; import {MatInputModule} from '@angular/material/input';
import { MatInputModule } from '@angular/material/input'; import {SharedModule} from 'app/shared/shared.module';
import { SharedModule } from 'app/shared/shared.module';
import {DetailSettingsComponent} from 'app/layout/common/detail-settings/detail-settings.component' import {DetailSettingsComponent} from 'app/layout/common/detail-settings/detail-settings.component'
import { MatDialogModule } from '@angular/material/dialog'; import {MatDialogModule} from '@angular/material/dialog';
import { MatButtonToggleModule} from '@angular/material/button-toggle'; import {MatButtonToggleModule} from '@angular/material/button-toggle';
import {MatTabsModule} from '@angular/material/tabs'; import {MatTabsModule} from '@angular/material/tabs';
import {MatSliderModule} from '@angular/material/slider'; import {MatSliderModule} from '@angular/material/slider';
import {MatSlideToggleModule} from '@angular/material/slide-toggle'; import {MatSlideToggleModule} from '@angular/material/slide-toggle';
@ -20,7 +19,7 @@ import {MatTooltipModule} from '@angular/material/tooltip';
declarations: [ declarations: [
DetailSettingsComponent DetailSettingsComponent
], ],
imports : [ imports: [
RouterModule.forChild([]), RouterModule.forChild([]),
MatAutocompleteModule, MatAutocompleteModule,
MatDialogModule, MatDialogModule,
@ -36,11 +35,10 @@ import {MatTooltipModule} from '@angular/material/tooltip';
MatSlideToggleModule, MatSlideToggleModule,
SharedModule SharedModule
], ],
exports : [ exports: [
DetailSettingsComponent DetailSettingsComponent
], ],
providers : [] providers: []
}) })
export class DetailSettingsModule export class DetailSettingsModule {
{
} }

@ -1,5 +1,5 @@
<div *ngIf="data && data.data && data.data.summary; else emptyDashboard"> <div *ngIf="summaryData; else emptyDashboard">
<div class="flex flex-col flex-auto w-full p-8 xs:p-2"> <div class="flex flex-col flex-auto w-full p-8 xs:p-2">
<div class="flex flex-wrap w-full"> <div class="flex flex-wrap w-full">

@ -1,17 +1,24 @@
import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import {
import { MatSort } from '@angular/material/sort'; AfterViewInit,
import { MatTableDataSource } from '@angular/material/table'; ChangeDetectionStrategy,
import { Subject } from 'rxjs'; Component,
import { takeUntil } from 'rxjs/operators'; OnDestroy,
OnInit,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {Subject} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
import {ApexOptions, ChartComponent} from 'ng-apexcharts'; import {ApexOptions, ChartComponent} from 'ng-apexcharts';
import { DashboardService } from 'app/modules/dashboard/dashboard.service'; import {DashboardService} from 'app/modules/dashboard/dashboard.service';
import {MatDialog} from '@angular/material/dialog'; import {MatDialog} from '@angular/material/dialog';
import { DashboardSettingsComponent } from 'app/layout/common/dashboard-settings/dashboard-settings.component'; import {DashboardSettingsComponent} from 'app/layout/common/dashboard-settings/dashboard-settings.component';
import {AppConfig} from 'app/core/config/app.config'; import {AppConfig} from 'app/core/config/app.config';
import {TreoConfigService} from '@treo/services/config'; import {TreoConfigService} from '@treo/services/config';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {TemperaturePipe} from 'app/shared/temperature.pipe'; import {TemperaturePipe} from 'app/shared/temperature.pipe';
import {DeviceTitlePipe} from 'app/shared/device-title.pipe'; import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
@Component({ @Component({
selector : 'example', selector : 'example',
@ -22,7 +29,7 @@ import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
}) })
export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
{ {
data: any; summaryData: { [key: string]: DeviceSummaryModel };
hostGroups: { [hostId: string]: string[] } = {} hostGroups: { [hostId: string]: string[] } = {}
temperatureOptions: ApexOptions; temperatureOptions: ApexOptions;
tempDurationKey = 'forever' tempDurationKey = 'forever'
@ -35,10 +42,13 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
/** /**
* Constructor * Constructor
* *
* @param {SmartService} _smartService * @param {DashboardService} _dashboardService
* @param {TreoConfigService} _configService
* @param {MatDialog} dialog
* @param {Router} router
*/ */
constructor( constructor(
private _smartService: DashboardService, private _dashboardService: DashboardService,
private _configService: TreoConfigService, private _configService: TreoConfigService,
public dialog: MatDialog, public dialog: MatDialog,
private router: Router, private router: Router,
@ -81,16 +91,16 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
}); });
// Get the data // Get the data
this._smartService.data$ this._dashboardService.data$
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe((data) => { .subscribe((data) => {
// Store the data // Store the data
this.data = data; this.summaryData = data;
// generate group data. // generate group data.
for(const wwn in this.data.data.summary){ for (const wwn in this.summaryData) {
const hostid = this.data.data.summary[wwn].device.host_id const hostid = this.summaryData[wwn].device.host_id
const hostDeviceList = this.hostGroups[hostid] || [] const hostDeviceList = this.hostGroups[hostid] || []
hostDeviceList.push(wwn) hostDeviceList.push(wwn)
this.hostGroups[hostid] = hostDeviceList this.hostGroups[hostid] = hostDeviceList
@ -132,11 +142,11 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
private _deviceDataTemperatureSeries(): any[] { private _deviceDataTemperatureSeries(): any[] {
const deviceTemperatureSeries = [] const deviceTemperatureSeries = []
console.log('DEVICE DATA SUMMARY', this.data) console.log('DEVICE DATA SUMMARY', this.summaryData)
for(const wwn in this.data.data.summary){ for (const wwn in this.summaryData) {
const deviceSummary = this.data.data.summary[wwn] const deviceSummary = this.summaryData[wwn]
if (!deviceSummary.temp_history){ if (!deviceSummary.temp_history) {
continue continue
} }
@ -206,7 +216,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
} }
} }
}, },
xaxis : { xaxis: {
type: 'datetime' type: 'datetime'
} }
}; };
@ -216,11 +226,11 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
// @ Public methods // @ Public methods
// ----------------------------------------------------------------------------------------------------- // -----------------------------------------------------------------------------------------------------
deviceSummariesForHostGroup(hostGroupWWNs: string[]): any[] { deviceSummariesForHostGroup(hostGroupWWNs: string[]): DeviceSummaryModel[] {
const deviceSummaries = [] const deviceSummaries: DeviceSummaryModel[] = []
for(const wwn of hostGroupWWNs){ for (const wwn of hostGroupWWNs) {
if(this.data.data.summary[wwn]){ if (this.summaryData[wwn]) {
deviceSummaries.push(this.data.data.summary[wwn]) deviceSummaries.push(this.summaryData[wwn])
} }
} }
return deviceSummaries return deviceSummaries
@ -235,7 +245,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
} }
onDeviceDeleted(wwn: string): void { onDeviceDeleted(wwn: string): void {
delete this.data.data.summary[wwn] // remove the device from the summary list. delete this.summaryData[wwn] // remove the device from the summary list.
} }
/* /*
@ -246,16 +256,16 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
DURATION_KEY_FOREVER = "forever" DURATION_KEY_FOREVER = "forever"
*/ */
changeSummaryTempDuration(durationKey: string){ changeSummaryTempDuration(durationKey: string): void {
this.tempDurationKey = durationKey this.tempDurationKey = durationKey
this._smartService.getSummaryTempData(durationKey) this._dashboardService.getSummaryTempData(durationKey)
.subscribe((data) => { .subscribe((tempHistoryData) => {
// given a list of device temp history, override the data in the "summary" object. // given a list of device temp history, override the data in the "summary" object.
for(const wwn in this.data.data.summary) { for (const wwn in this.summaryData) {
// console.log(`Updating ${wwn}, length: ${this.data.data.summary[wwn].temp_history.length}`) // console.log(`Updating ${wwn}, length: ${this.data.data.summary[wwn].temp_history.length}`)
this.data.data.summary[wwn].temp_history = data.data.temp_history[wwn] || [] this.summaryData[wwn].temp_history = tempHistoryData[wwn] || []
} }
// Prepare the chart series data // Prepare the chart series data

@ -1,13 +1,13 @@
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router';
import { Observable } from 'rxjs'; import {Observable} from 'rxjs';
import { DashboardService } from 'app/modules/dashboard/dashboard.service'; import {DashboardService} from 'app/modules/dashboard/dashboard.service';
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class DashboardResolver implements Resolve<any> export class DashboardResolver implements Resolve<any> {
{
/** /**
* Constructor * Constructor
* *
@ -29,8 +29,7 @@ export class DashboardResolver implements Resolve<any>
* @param route * @param route
* @param state * @param state
*/ */
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any> resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<{ [p: string]: DeviceSummaryModel }> {
{
return this._dashboardService.getSummaryData(); return this._dashboardService.getSummaryData();
} }
} }

@ -0,0 +1,44 @@
import {HttpClient} from '@angular/common/http';
import {DashboardService} from './dashboard.service';
import {of} from 'rxjs';
import {summary} from 'app/data/mock/summary/data'
import {temp_history} from 'app/data/mock/summary/temp_history'
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
import {SmartTemperatureModel} from 'app/core/models/measurements/smart-temperature-model';
describe('DashboardService', () => {
let service: DashboardService;
let httpClientSpy: jasmine.SpyObj<HttpClient>;
beforeEach(() => {
httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
service = new DashboardService(httpClientSpy);
});
it('should unwrap and return getSummaryData() (HttpClient called once)', (done: DoneFn) => {
httpClientSpy.get.and.returnValue(of(summary));
service.getSummaryData().subscribe(value => {
expect(value).toBe(summary.data.summary as { [key: string]: DeviceSummaryModel });
done();
});
expect(httpClientSpy.get.calls.count())
.withContext('one call')
.toBe(1);
});
it('should unwrap and return getSummaryTempData() (HttpClient called once)', (done: DoneFn) => {
// const expectedHeroes: any[] =
// [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
httpClientSpy.get.and.returnValue(of(temp_history));
service.getSummaryTempData('weekly').subscribe(value => {
expect(value).toBe(temp_history.data.temp_history as { [key: string]: SmartTemperatureModel[] });
done();
});
expect(httpClientSpy.get.calls.count())
.withContext('one call')
.toBe(1);
});
});

@ -1,16 +1,19 @@
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import { HttpClient } from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs'; import {BehaviorSubject, Observable} from 'rxjs';
import { tap } from 'rxjs/operators'; import {map, tap} from 'rxjs/operators';
import { getBasePath } from 'app/app.routing'; import {getBasePath} from 'app/app.routing';
import {DeviceSummaryResponseWrapper} from 'app/core/models/device-summary-response-wrapper';
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
import {SmartTemperatureModel} from 'app/core/models/measurements/smart-temperature-model';
import {DeviceSummaryTempResponseWrapper} from 'app/core/models/device-summary-temp-response-wrapper';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class DashboardService export class DashboardService {
{
// Observables // Observables
private _data: BehaviorSubject<any>; private _data: BehaviorSubject<{ [p: string]: DeviceSummaryModel }>;
/** /**
* Constructor * Constructor
@ -32,8 +35,7 @@ export class DashboardService
/** /**
* Getter for data * Getter for data
*/ */
get data$(): Observable<any> get data$(): Observable<{ [p: string]: DeviceSummaryModel }> {
{
return this._data.asObservable(); return this._data.asObservable();
} }
@ -44,22 +46,28 @@ export class DashboardService
/** /**
* Get data * Get data
*/ */
getSummaryData(): Observable<any> getSummaryData(): Observable<{ [key: string]: DeviceSummaryModel }> {
{
return this._httpClient.get(getBasePath() + '/api/summary').pipe( return this._httpClient.get(getBasePath() + '/api/summary').pipe(
tap((response: any) => { map((response: DeviceSummaryResponseWrapper) => {
// console.log("FILTERING=----", response.data.summary)
return response.data.summary
}),
tap((response: { [key: string]: DeviceSummaryModel }) => {
this._data.next(response); this._data.next(response);
}) })
); );
} }
getSummaryTempData(durationKey: string): Observable<any> getSummaryTempData(durationKey: string): Observable<{ [key: string]: SmartTemperatureModel[] }> {
{
const params = {} const params = {}
if(durationKey){ if (durationKey) {
params['duration_key'] = durationKey params['duration_key'] = durationKey
} }
return this._httpClient.get(getBasePath() + '/api/summary/temp', {params: params}); return this._httpClient.get(getBasePath() + '/api/summary/temp', {params: params}).pipe(
map((response: DeviceSummaryTempResponseWrapper) => {
return response.data.temp_history
})
);
} }
} }

@ -1,25 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DetailComponent } from './detail.component';
describe('DetailComponent', () => {
let component: DetailComponent;
let fixture: ComponentFixture<DetailComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ DetailComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,18 +1,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 {ApexOptions} from 'ng-apexcharts';
import {MatTableDataSource} from '@angular/material/table'; import {AppConfig} from 'app/core/config/app.config';
import {MatSort} from '@angular/material/sort';
import {Subject} from 'rxjs';
import {DetailService} from './detail.service'; import {DetailService} from './detail.service';
import {takeUntil} from 'rxjs/operators';
import {DetailSettingsComponent} from 'app/layout/common/detail-settings/detail-settings.component'; import {DetailSettingsComponent} from 'app/layout/common/detail-settings/detail-settings.component';
import {MatDialog} from '@angular/material/dialog'; import {MatDialog} from '@angular/material/dialog';
import humanizeDuration from 'humanize-duration'; import {MatSort} from '@angular/material/sort';
import {MatTableDataSource} from '@angular/material/table';
import {Subject} from 'rxjs';
import {TreoConfigService} from '@treo/services/config'; import {TreoConfigService} from '@treo/services/config';
import {AppConfig} from 'app/core/config/app.config';
import {animate, state, style, transition, trigger} from '@angular/animations'; import {animate, state, style, transition, trigger} from '@angular/animations';
import {formatDate} from '@angular/common'; import {formatDate} from '@angular/common';
import { LOCALE_ID, Inject } from '@angular/core'; import {takeUntil} from 'rxjs/operators';
import {DeviceModel} from 'app/core/models/device-model';
import {SmartModel} from 'app/core/models/measurements/smart-model';
import {SmartAttributeModel} from 'app/core/models/measurements/smart-attribute-model';
import {AttributeMetadataModel} from 'app/core/models/thresholds/attribute-metadata-model';
// from Constants.go - these must match // from Constants.go - these must match
const AttributeStatusPassed = 0 const AttributeStatusPassed = 0
@ -40,22 +43,23 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
* Constructor * Constructor
* *
* @param {DetailService} _detailService * @param {DetailService} _detailService
* @param {MatDialog} dialog
* @param {TreoConfigService} _configService
* @param {string} locale
*/ */
constructor( constructor(
private _detailService: DetailService, private _detailService: DetailService,
public dialog: MatDialog, public dialog: MatDialog,
private _configService: TreoConfigService, private _configService: TreoConfigService,
@Inject(LOCALE_ID) public locale: string @Inject(LOCALE_ID) public locale: string
) {
)
{
// Set the private defaults // Set the private defaults
this._unsubscribeAll = new Subject(); this._unsubscribeAll = new Subject();
// Set the defaults // Set the defaults
this.smartAttributeDataSource = new MatTableDataSource(); this.smartAttributeDataSource = new MatTableDataSource();
// this.recentTransactionsTableColumns = ['status', 'id', 'name', 'value', 'worst', 'thresh']; // this.recentTransactionsTableColumns = ['status', 'id', 'name', 'value', 'worst', 'thresh'];
this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'worst', 'thresh','ideal', 'failure', 'history']; this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'worst', 'thresh', 'ideal', 'failure', 'history'];
this.systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; this.systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
@ -65,14 +69,15 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
onlyCritical = true; onlyCritical = true;
// data: any; // data: any;
expandedAttribute: any | null; expandedAttribute: SmartAttributeModel | null;
metadata: any; metadata: { [p: string]: AttributeMetadataModel } | { [p: number]: AttributeMetadataModel };
device: any; device: DeviceModel;
smart_results: any[]; // tslint:disable-next-line:variable-name
smart_results: SmartModel[];
commonSparklineOptions: Partial<ApexOptions>; commonSparklineOptions: Partial<ApexOptions>;
smartAttributeDataSource: MatTableDataSource<any>; smartAttributeDataSource: MatTableDataSource<SmartAttributeModel>;
smartAttributeTableColumns: string[]; smartAttributeTableColumns: string[];
@ViewChild('smartAttributeTable', {read: MatSort}) @ViewChild('smartAttributeTable', {read: MatSort})
@ -91,8 +96,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
/** /**
* On init * On init
*/ */
ngOnInit(): void ngOnInit(): void {
{
// Subscribe to config changes // Subscribe to config changes
this._configService.config$ this._configService.config$
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
@ -104,13 +108,13 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
// Get the data // Get the data
this._detailService.data$ this._detailService.data$
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe((data) => { .subscribe((respWrapper) => {
// Store the data // Store the data
// this.data = data; // this.data = data;
this.device = data.data.device; this.device = respWrapper.data.device;
this.smart_results = data.data.smart_results this.smart_results = respWrapper.data.smart_results
this.metadata = data.metadata; this.metadata = respWrapper.metadata;
// Store the table data // Store the table data
@ -124,8 +128,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
/** /**
* After view init * After view init
*/ */
ngAfterViewInit(): void ngAfterViewInit(): void {
{
// Make the data source sortable // Make the data source sortable
this.smartAttributeDataSource.sort = this.smartAttributeTableMatSort; this.smartAttributeDataSource.sort = this.smartAttributeTableMatSort;
} }
@ -133,8 +136,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
/** /**
* On destroy * On destroy
*/ */
ngOnDestroy(): void ngOnDestroy(): void {
{
// Unsubscribe from all subscriptions // Unsubscribe from all subscriptions
this._unsubscribeAll.next(); this._unsubscribeAll.next();
this._unsubscribeAll.complete(); this._unsubscribeAll.complete();
@ -147,22 +149,23 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
getAttributeStatusName(attributeStatus: number): string { getAttributeStatusName(attributeStatus: number): string {
// tslint:disable:no-bitwise // tslint:disable:no-bitwise
if(attributeStatus === AttributeStatusPassed){ if (attributeStatus === AttributeStatusPassed) {
return 'passed' return 'passed'
} else if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0 || (attributeStatus & AttributeStatusFailedSmart) !== 0 ){ } else if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0 || (attributeStatus & AttributeStatusFailedSmart) !== 0) {
return 'failed' return 'failed'
} else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0){ } else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0) {
return 'warn' return 'warn'
} }
return '' return ''
// tslint:enable:no-bitwise // tslint:enable:no-bitwise
} }
getAttributeScrutinyStatusName(attributeStatus: number): string { getAttributeScrutinyStatusName(attributeStatus: number): string {
// tslint:disable:no-bitwise // tslint:disable:no-bitwise
if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0){ if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0) {
return 'failed' return 'failed'
} else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0){ } else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0) {
return 'warn' return 'warn'
} else { } else {
return 'passed' return 'passed'
@ -172,7 +175,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
getAttributeSmartStatusName(attributeStatus: number): string { getAttributeSmartStatusName(attributeStatus: number): string {
// tslint:disable:no-bitwise // tslint:disable:no-bitwise
if ((attributeStatus & AttributeStatusFailedSmart) !== 0){ if ((attributeStatus & AttributeStatusFailedSmart) !== 0) {
return 'failed' return 'failed'
} else { } else {
return 'passed' return 'passed'
@ -181,138 +184,140 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
} }
getAttributeName(attribute_data): string { getAttributeName(attributeData: SmartAttributeModel): string {
const attribute_metadata = this.metadata[attribute_data.attribute_id] const attributeMetadata = this.metadata[attributeData.attribute_id]
if(!attribute_metadata){ if (!attributeMetadata) {
return 'Unknown Attribute Name' return 'Unknown Attribute Name'
} else { } else {
return attribute_metadata.display_name return attributeMetadata.display_name
} }
} }
getAttributeDescription(attribute_data){
const attribute_metadata = this.metadata[attribute_data.attribute_id] getAttributeDescription(attributeData: SmartAttributeModel): string {
if(!attribute_metadata){ const attributeMetadata = this.metadata[attributeData.attribute_id]
if (!attributeMetadata) {
return 'Unknown' return 'Unknown'
} else { } else {
return attribute_metadata.description return attributeMetadata.description
} }
return
} }
getAttributeValue(attribute_data){ getAttributeValue(attributeData: SmartAttributeModel): number {
if(this.isAta()) { if (this.isAta()) {
const attribute_metadata = this.metadata[attribute_data.attribute_id] const attributeMetadata = this.metadata[attributeData.attribute_id]
if(!attribute_metadata){ if (!attributeMetadata) {
return attribute_data.value return attributeData.value
} else if (attribute_metadata.display_type == 'raw') { } else if (attributeMetadata.display_type === 'raw') {
return attribute_data.raw_value return attributeData.raw_value
} else if (attribute_metadata.display_type == 'transformed' && attribute_data.transformed_value) { } else if (attributeMetadata.display_type === 'transformed' && attributeData.transformed_value) {
return attribute_data.transformed_value return attributeData.transformed_value
} else { } else {
return attribute_data.value return attributeData.value
} }
} } else {
else{ return attributeData.value
return attribute_data.value
} }
} }
getAttributeValueType(attribute_data){ getAttributeValueType(attributeData: SmartAttributeModel): string {
if(this.isAta()) { if (this.isAta()) {
const attribute_metadata = this.metadata[attribute_data.attribute_id] const attributeMetadata = this.metadata[attributeData.attribute_id]
if(!attribute_metadata){ if (!attributeMetadata) {
return '' return ''
} else { } else {
return attribute_metadata.display_type return attributeMetadata.display_type
} }
} else { } else {
return '' return ''
} }
} }
getAttributeIdeal(attribute_data){ getAttributeIdeal(attributeData: SmartAttributeModel): string {
if(this.isAta()){ if (this.isAta()) {
return this.metadata[attribute_data.attribute_id]?.display_type == 'raw' ? this.metadata[attribute_data.attribute_id]?.ideal : '' return this.metadata[attributeData.attribute_id]?.display_type === 'raw' ? this.metadata[attributeData.attribute_id]?.ideal : ''
} else { } else {
return this.metadata[attribute_data.attribute_id]?.ideal return this.metadata[attributeData.attribute_id]?.ideal
} }
} }
getAttributeWorst(attribute_data){ getAttributeWorst(attributeData: SmartAttributeModel): number | string {
const attribute_metadata = this.metadata[attribute_data.attribute_id] const attributeMetadata = this.metadata[attributeData.attribute_id]
if(!attribute_metadata){ if (!attributeMetadata) {
return attribute_data.worst return attributeData.worst
} else { } else {
return attribute_metadata?.display_type == 'normalized' ? attribute_data.worst : '' return attributeMetadata?.display_type === 'normalized' ? attributeData.worst : ''
} }
} }
getAttributeThreshold(attribute_data){ getAttributeThreshold(attributeData: SmartAttributeModel): number | string {
if(this.isAta()){ if (this.isAta()) {
const attribute_metadata = this.metadata[attribute_data.attribute_id] const attributeMetadata = this.metadata[attributeData.attribute_id]
if(!attribute_metadata || attribute_metadata.display_type == 'normalized'){ if (!attributeMetadata || attributeMetadata.display_type === 'normalized') {
return attribute_data.thresh return attributeData.thresh
} else { } else {
// if(this.data.metadata[attribute_data.attribute_id].observed_thresholds){ // if(this.data.metadata[attribute_data.attribute_id].observed_thresholds){
// //
// } else { // } else {
// } // }
// return '' // return ''
return attribute_data.thresh return attributeData.thresh
} }
} else { } else {
return (attribute_data.thresh == -1 ? '' : attribute_data.thresh ) return (attributeData.thresh === -1 ? '' : attributeData.thresh)
} }
} }
getAttributeCritical(attribute_data){ getAttributeCritical(attributeData: SmartAttributeModel): boolean {
return this.metadata[attribute_data.attribute_id]?.critical return this.metadata[attributeData.attribute_id]?.critical
} }
getHiddenAttributes(){
if (!this.smart_results || this.smart_results.length == 0) { getHiddenAttributes(): number {
if (!this.smart_results || this.smart_results.length === 0) {
return 0 return 0
} }
let attributes_length = 0 let attributesLength = 0
const attributes = this.smart_results[0]?.attrs const attributes = this.smart_results[0]?.attrs
if (attributes) { if (attributes) {
attributes_length = Object.keys(attributes).length attributesLength = Object.keys(attributes).length
} }
return attributes_length - this.smartAttributeDataSource.data.length return attributesLength - this.smartAttributeDataSource.data.length
} }
isAta(): boolean { isAta(): boolean {
return this.device.device_protocol == 'ATA' return this.device.device_protocol === 'ATA'
} }
isScsi(): boolean { isScsi(): boolean {
return this.device.device_protocol == 'SCSI' return this.device.device_protocol === 'SCSI'
} }
isNvme(): boolean { isNvme(): boolean {
return this.device.device_protocol == 'NVMe' return this.device.device_protocol === 'NVMe'
} }
private _generateSmartAttributeTableDataSource(smart_results){ private _generateSmartAttributeTableDataSource(smartResults: SmartModel[]): SmartAttributeModel[] {
const smartAttributeDataSource = []; const smartAttributeDataSource: SmartAttributeModel[] = [];
if(smart_results.length == 0){ if (smartResults.length === 0) {
return smartAttributeDataSource return smartAttributeDataSource
} }
const latest_smart_result = smart_results[0]; const latestSmartResult = smartResults[0];
let attributes = {} let attributes: { [p: string]: SmartAttributeModel } = {}
if(this.isScsi()) { if (this.isScsi()) {
this.smartAttributeTableColumns = ['status', 'name', 'value', 'thresh', 'history']; this.smartAttributeTableColumns = ['status', 'name', 'value', 'thresh', 'history'];
attributes = latest_smart_result.attrs attributes = latestSmartResult.attrs
} else if(this.isNvme()){ } else if (this.isNvme()) {
this.smartAttributeTableColumns = ['status', 'name', 'value', 'thresh', 'ideal', 'history']; this.smartAttributeTableColumns = ['status', 'name', 'value', 'thresh', 'ideal', 'history'];
attributes = latest_smart_result.attrs attributes = latestSmartResult.attrs
} else { } else {
// ATA // ATA
attributes = latest_smart_result.attrs attributes = latestSmartResult.attrs
this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'thresh','ideal', 'failure', 'history']; this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'thresh', 'ideal', 'failure', 'history'];
} }
for(const attrId in attributes){ for (const attrId in attributes) {
const attr = attributes[attrId] const attr = attributes[attrId]
// chart history data // chart history data
@ -320,18 +325,18 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
const attrHistory = [] const attrHistory = []
for (const smart_result of smart_results){ for (const smartResult of smartResults) {
// attrHistory.push(this.getAttributeValue(smart_result.attrs[attrId])) // attrHistory.push(this.getAttributeValue(smart_result.attrs[attrId]))
const chartDatapoint = { const chartDatapoint = {
x: formatDate(smart_result.date, 'MMMM dd, yyyy - HH:mm', this.locale), x: formatDate(smartResult.date, 'MMMM dd, yyyy - HH:mm', this.locale),
y: this.getAttributeValue(smart_result.attrs[attrId]) y: this.getAttributeValue(smartResult.attrs[attrId])
} }
const attributeStatusName = this.getAttributeStatusName(smart_result.attrs[attrId].status) const attributeStatusName = this.getAttributeStatusName(smartResult.attrs[attrId].status)
if(attributeStatusName === 'failed') { if (attributeStatusName === 'failed') {
chartDatapoint['strokeColor'] = '#F05252' chartDatapoint['strokeColor'] = '#F05252'
chartDatapoint['fillColor'] = '#F05252' chartDatapoint['fillColor'] = '#F05252'
} else if (attributeStatusName === 'warn'){ } else if (attributeStatusName === 'warn') {
chartDatapoint['strokeColor'] = '#C27803' chartDatapoint['strokeColor'] = '#C27803'
chartDatapoint['fillColor'] = '#C27803' chartDatapoint['fillColor'] = '#C27803'
} }
@ -350,7 +355,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
} }
// determine when to include the attributes in table. // determine when to include the attributes in table.
if(!this.onlyCritical || this.onlyCritical && this.metadata[attr.attribute_id]?.critical || attr.value < attr.thresh){ if (!this.onlyCritical || this.onlyCritical && this.metadata[attr.attribute_id]?.critical || attr.value < attr.thresh) {
smartAttributeDataSource.push(attr) smartAttributeDataSource.push(attr)
} }
} }
@ -362,8 +367,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
* *
* @private * @private
*/ */
private _prepareChartData(): void private _prepareChartData(): void {
{
// Account balance // Account balance
this.commonSparklineOptions = { this.commonSparklineOptions = {
@ -392,7 +396,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
}, },
y: { y: {
title: { title: {
formatter: function(seriesName) { formatter: (seriesName) => {
return ''; 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') { if (config.theme === 'system') {
return this.systemPrefersDark ? 'dark' : 'light' return this.systemPrefersDark ? 'dark' : 'light'
} else { } else {
return config.theme return config.theme
} }
} }
// ----------------------------------------------------------------------------------------------------- // -----------------------------------------------------------------------------------------------------
// @ Public methods // @ Public methods
// ----------------------------------------------------------------------------------------------------- // -----------------------------------------------------------------------------------------------------
toHex(decimalNumb){ toHex(decimalNumb: number | string): string {
return '0x' + Number(decimalNumb).toString(16).padStart(2, '0').toUpperCase() return '0x' + Number(decimalNumb).toString(16).padStart(2, '0').toUpperCase()
} }
toggleOnlyCritical(){
toggleOnlyCritical(): void {
this.onlyCritical = !this.onlyCritical this.onlyCritical = !this.onlyCritical
this.smartAttributeDataSource.data = this._generateSmartAttributeTableDataSource(this.smart_results); this.smartAttributeDataSource.data = this._generateSmartAttributeTableDataSource(this.smart_results);
} }
openDialog() { openDialog(): void {
const dialogRef = this.dialog.open(DetailSettingsComponent); const dialogRef = this.dialog.open(DetailSettingsComponent);
dialogRef.afterClosed().subscribe(result => { dialogRef.afterClosed().subscribe(result => {
@ -444,8 +449,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy {
* @param index * @param index
* @param item * @param item
*/ */
trackByFn(index: number, item: any): any trackByFn(index: number, item: any): any {
{
return index; return index;
// return item.id || index; // return item.id || index;
} }

@ -1,13 +1,13 @@
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router';
import { Observable } from 'rxjs'; import {Observable} from 'rxjs';
import { DetailService } from 'app/modules/detail/detail.service'; import {DetailService} from 'app/modules/detail/detail.service';
import {DeviceDetailsResponseWrapper} from 'app/core/models/device-details-response-wrapper';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class DetailResolver implements Resolve<any> export class DetailResolver implements Resolve<any> {
{
/** /**
* Constructor * Constructor
* *
@ -29,8 +29,7 @@ export class DetailResolver implements Resolve<any>
* @param route * @param route
* @param state * @param state
*/ */
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any> resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<DeviceDetailsResponseWrapper> {
{
return this._detailService.getData(route.params.wwn); return this._detailService.getData(route.params.wwn);
} }
} }

@ -0,0 +1,28 @@
import {HttpClient} from '@angular/common/http';
import {DetailService} from './detail.service';
import {of} from 'rxjs';
import {sda} from 'app/data/mock/device/details/sda'
import {DeviceDetailsResponseWrapper} from 'app/core/models/device-details-response-wrapper';
describe('DetailService', () => {
describe('#getData', () => {
let service: DetailService;
let httpClientSpy: jasmine.SpyObj<HttpClient>;
beforeEach(() => {
httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
service = new DetailService(httpClientSpy);
});
it('should return getData() (HttpClient called once)', (done: DoneFn) => {
httpClientSpy.get.and.returnValue(of(sda));
service.getData('test').subscribe(value => {
expect(value).toBe(sda as DeviceDetailsResponseWrapper);
done();
});
expect(httpClientSpy.get.calls.count())
.withContext('one call')
.toBe(1);
});
})
});

@ -1,16 +1,16 @@
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import { HttpClient } from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs'; import {BehaviorSubject, Observable} from 'rxjs';
import { tap } from 'rxjs/operators'; import {tap} from 'rxjs/operators';
import { getBasePath } from 'app/app.routing'; import {getBasePath} from 'app/app.routing';
import {DeviceDetailsResponseWrapper} from 'app/core/models/device-details-response-wrapper';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class DetailService export class DetailService {
{
// Observables // Observables
private _data: BehaviorSubject<any>; private _data: BehaviorSubject<DeviceDetailsResponseWrapper>;
/** /**
* Constructor * Constructor
@ -19,8 +19,7 @@ export class DetailService
*/ */
constructor( constructor(
private _httpClient: HttpClient private _httpClient: HttpClient
) ) {
{
// Set the private defaults // Set the private defaults
this._data = new BehaviorSubject(null); this._data = new BehaviorSubject(null);
} }
@ -32,8 +31,7 @@ export class DetailService
/** /**
* Getter for data * Getter for data
*/ */
get data$(): Observable<any> get data$(): Observable<DeviceDetailsResponseWrapper> {
{
return this._data.asObservable(); return this._data.asObservable();
} }
@ -44,10 +42,9 @@ export class DetailService
/** /**
* Get data * Get data
*/ */
getData(wwn): Observable<any> getData(wwn): Observable<DeviceDetailsResponseWrapper> {
{
return this._httpClient.get(getBasePath() + `/api/device/${wwn}/details`).pipe( return this._httpClient.get(getBasePath() + `/api/device/${wwn}/details`).pipe(
tap((response: any) => { tap((response: DeviceDetailsResponseWrapper) => {
this._data.next(response); this._data.next(response);
}) })
); );

@ -1,8 +1,151 @@
import { DeviceTitlePipe } from './device-title.pipe'; import {DeviceTitlePipe} from './device-title.pipe';
import {DeviceModel} from 'app/core/models/device-model';
describe('DeviceTitlePipe', () => { describe('DeviceTitlePipe', () => {
it('create an instance', () => { it('create an instance', () => {
const pipe = new DeviceTitlePipe(); const pipe = new DeviceTitlePipe();
expect(pipe).toBeTruthy(); expect(pipe).toBeTruthy();
}); });
describe('#deviceTitleForType', () => {
const testCases = [
{
'device': {
'device_name': 'sda',
'device_type': 'ata',
'model_name': 'Samsung',
},
'titleType': 'name',
'result': '/dev/sda - Samsung'
},{
'device': {
'device_name': 'nvme0',
'device_type': 'nvme',
'model_name': 'Samsung',
},
'titleType': 'name',
'result': '/dev/nvme0 - nvme - Samsung'
},{
'device': {},
'titleType': 'serial_id',
'result': ''
},{
'device': {
'device_serial_id': 'ata-WDC_WD140EDFZ-11AXXXXX_9RXXXXXX',
},
'titleType': 'serial_id',
'result': '/by-id/ata-WDC_WD140EDFZ-11AXXXXX_9RXXXXXX'
},{
'device': {},
'titleType': 'uuid',
'result': ''
},{
'device': {
'device_uuid': 'abcdef-1234-4567-8901'
},
'titleType': 'uuid',
'result': '/by-uuid/abcdef-1234-4567-8901'
},{
'device': {},
'titleType': 'label',
'result': ''
},{
'device': {
'label': 'custom-device-label'
},
'titleType': 'label',
'result': 'custom-device-label'
},{
'device': {
'device_label': 'drive-volume-label'
},
'titleType': 'label',
'result': '/by-label/drive-volume-label'
},
]
testCases.forEach((test, index) => {
it(`should correctly format device title ${JSON.stringify(test.device)}. (testcase: ${index + 1})`, () => {
// test
const formatted = DeviceTitlePipe.deviceTitleForType(test.device as DeviceModel, test.titleType)
expect(formatted).toEqual(test.result);
});
})
})
describe('#deviceTitleWithFallback',() => {
const testCases = [
{
'device': {
'device_name': 'sda',
'device_type': 'ata',
'model_name': 'Samsung',
},
'titleType': 'name',
'result': '/dev/sda - Samsung'
},{
'device': {
'device_name': 'nvme0',
'device_type': 'nvme',
'model_name': 'Samsung',
},
'titleType': 'name',
'result': '/dev/nvme0 - nvme - Samsung'
},{
'device': {
'device_name': 'fallback',
'device_type': 'ata',
'model_name': 'fallback',
},
'titleType': 'serial_id',
'result': '/dev/fallback - fallback'
},{
'device': {
'device_serial_id': 'ata-WDC_WD140EDFZ-11AXXXXX_9RXXXXXX',
},
'titleType': 'serial_id',
'result': '/by-id/ata-WDC_WD140EDFZ-11AXXXXX_9RXXXXXX'
},{
'device': {
'device_name': 'fallback',
'device_type': 'ata',
'model_name': 'fallback',
},
'titleType': 'uuid',
'result': '/dev/fallback - fallback'
},{
'device': {
'device_uuid': 'abcdef-1234-4567-8901'
},
'titleType': 'uuid',
'result': '/by-uuid/abcdef-1234-4567-8901'
},{
'device': {
'device_name': 'fallback',
'device_type': 'ata',
'model_name': 'fallback',
},
'titleType': 'label',
'result': '/dev/fallback - fallback'
},{
'device': {
'label': 'custom-device-label'
},
'titleType': 'label',
'result': 'custom-device-label'
},{
'device': {
'device_label': 'drive-volume-label'
},
'titleType': 'label',
'result': '/by-label/drive-volume-label'
},
]
testCases.forEach((test, index) => {
it(`should correctly format device title ${JSON.stringify(test.device)}. (testcase: ${index + 1})`, () => {
// test
const formatted = DeviceTitlePipe.deviceTitleWithFallback(test.device as DeviceModel, test.titleType)
expect(formatted).toEqual(test.result);
});
})
})
}); });

@ -1,11 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core'; import {Pipe, PipeTransform} from '@angular/core';
import {DeviceModel} from 'app/core/models/device-model';
@Pipe({ @Pipe({
name: 'deviceTitle' name: 'deviceTitle'
}) })
export class DeviceTitlePipe implements PipeTransform { export class DeviceTitlePipe implements PipeTransform {
static deviceTitleForType(device: any, titleType: string): string { static deviceTitleForType(device: DeviceModel, titleType: string): string {
const titleParts = [] const titleParts = []
switch(titleType){ switch(titleType){
case 'name': case 'name':
@ -35,7 +36,7 @@ export class DeviceTitlePipe implements PipeTransform {
return titleParts.join(' - ') return titleParts.join(' - ')
} }
static deviceTitleWithFallback(device, titleType: string): string { static deviceTitleWithFallback(device: DeviceModel, titleType: string): string {
console.log(`Displaying Device ${device.wwn} with: ${titleType}`) console.log(`Displaying Device ${device.wwn} with: ${titleType}`)
const titleParts = [] const titleParts = []
if (device.host_id) titleParts.push(device.host_id) if (device.host_id) titleParts.push(device.host_id)
@ -47,7 +48,7 @@ export class DeviceTitlePipe implements PipeTransform {
} }
transform(device: any, titleType: string = 'name'): string { transform(device: DeviceModel, titleType: string = 'name'): string {
return DeviceTitlePipe.deviceTitleWithFallback(device, titleType) return DeviceTitlePipe.deviceTitleWithFallback(device, titleType)
} }

@ -0,0 +1,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);
});
})
})
});

@ -5,4 +5,79 @@ describe('TemperaturePipe', () => {
const pipe = new TemperaturePipe(); const pipe = new TemperaturePipe();
expect(pipe).toBeTruthy(); 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);
});
})
})
}); });

Loading…
Cancel
Save