Compare commits

..

67 Commits

Author SHA1 Message Date
Jason Kulatunga f8adf53e30 Change artifact download name to './' 2026-02-06 22:32:59 -05:00
Aram Akhavan 51f0ba6ee2 Update release workflows (#874)
* Bump action versions
* Merge frontend release into main release workflow
* Fix bugs with asset naming
2026-02-06 16:20:57 -08:00
Aram Akhavan 34b0347acd Fix release workflow (#873)
Newer version of upload-artifact requires unique artifact names, but the matrix was using the same name for all of them.
2026-02-06 12:29:15 -08:00
Jason Kulatunga 0565962a14 Check result of attribute casting to avoid panics (#528) 2026-02-05 22:14:03 -08:00
Liu Xiaoyi 184bc4bec5 Improve temperature logging (#825)
* Always log current temperature
* Forcefully align each ata_sct_temperature_history data point to an integer multiple of the logging interval to prevent repeated data points

Fixes #824
2026-02-05 21:35:35 -08:00
mcarbonne bdbe13e320 Add option to discard SCT Data Table Temperature History (#557)
Fixes #494
2026-02-05 20:59:24 -08:00
Aram Akhavan 761014a93f Fix codecov upload (#850)
Update Codecov action to version 5 and add token
2026-02-04 16:11:13 -08:00
Aram Akhavan 27be0b8327 Add AI Policy (#851) 2026-02-01 21:48:33 -08:00
Aram Akhavan 69abe43a1d Update authors (#849)
Add Aram Akhavan as a maintainer
2026-01-31 13:29:45 -08:00
Jason Kulatunga 7c35d59552 Merge pull request #784 from Peppercorn27/master
Fix web ui latency
2025-10-19 08:50:10 -04:00
Jason Kulatunga 742153e5dc Merge pull request #773 from Impact123/restart-policy
Unify docker restart policy among docs and example files
2025-10-19 08:43:40 -04:00
Jason Kulatunga 5f7e4a3808 Merge pull request #793 from pabsi/patch-1
feat: Update dashboard.component.ts
2025-08-14 11:38:02 -04:00
Pablo bb98b8c45b feat: Update dashboard.component.ts
Addresses https://github.com/AnalogJ/scrutiny/issues/755
2025-08-08 21:02:05 +02:00
Peppercorn27 b71897fa5f fix web ui latency
fix web ui latency in situations where cron shedule has been reduced resulting in more data being present in influxDB than expected
2025-07-06 17:11:42 +01:00
Impact a182c691fb Unify docker restart policy among docs and example files. 2025-05-06 05:07:56 +00:00
Jason Kulatunga 4066c84c8e Merge pull request #771 from RoboMagus/docker_semver_tags
Add docker semver tags
2025-04-30 10:32:48 -04:00
Jason Kulatunga 4a72c9ef55 Merge pull request #754 from Berry-95/491-FEAT-Allow-disks-to-be-archived
Fixes 491 [FEAT] Allow disks to be hidden/archived
2025-04-30 10:31:21 -04:00
Sam 3e11583283 491 [FEAT] Allow disks to be hidden/archived
- Fix mock device type definition mismatch in the frontend.
- Make DeviceModel archived field optional.
2025-04-28 15:01:31 +02:00
RoboMagus ea9799d963 Add docker semver tags 2025-04-24 22:38:31 +02:00
Jason Kulatunga e46ab7373e Merge pull request #739 from RickZaki/GHissue-643
fix: issue 643 - Fahrenheit values in graph were converted twice
2025-04-23 08:06:20 -04:00
Jason Kulatunga 87f923e1f2 Merge branch 'master' into GHissue-643 2025-04-11 07:16:29 -04:00
Sam Beresford 2244504023 Merge branch 'master' into 491-FEAT-Allow-disks-to-be-archived 2025-04-10 21:01:31 +01:00
Jason Kulatunga 192ae40f74 Merge pull request #744 from mcarbonne/fix_ci
Fix CI (conflicting artifact names)
2025-04-10 04:27:19 -04:00
Sam 600cd153e0 491 [FEAT] Allow disks to be hidden/archived
- Add archived to device type & db
- Add archive/unarchive handlers to webapp backend
- Add archive toggle & styling to webapp frontend
2025-02-21 09:25:27 +01:00
Maximilien Carbonne d11bf0a2fc fix CI (conflicting artifact names) 2025-01-26 21:51:51 +01:00
Rick Zaki 50561f34ea fix: https://github.com/AnalogJ/scrutiny/issues/643
needed to separate formatting temps from converting
dashboard was using format method to convert and send Fahrenheit values to chart, then passing the same method into chart formatter causing the Fahrenheit value to be passed in as Celsius and converted again.
2025-01-09 14:27:26 -05:00
Jason Kulatunga a58f9445c1 Merge pull request #619 from datenzar/override-config-with-env-variables
feat: Ability to override commands args
2025-01-08 10:46:10 -07:00
Jason Kulatunga 1ec478302f Merge pull request #737 from AnalogJ/AnalogJ-patch-1
Update TROUBLESHOOTING_DEVICE_COLLECTOR.md
2025-01-05 11:54:06 -07:00
Jason Kulatunga 412f956782 Update TROUBLESHOOTING_DEVICE_COLLECTOR.md 2025-01-05 11:53:43 -07:00
Jason Kulatunga 9b28ac5069 Update TESTERS.md 2025-01-04 17:46:17 -07:00
Jason Kulatunga db2869ffc6 Merge pull request #736 from AnalogJ/AnalogJ-patch-1
Update example.hubspoke.docker-compose.yml
2025-01-04 17:42:03 -07:00
Jason Kulatunga 6e349244d1 Update example.hubspoke.docker-compose.yml 2025-01-04 17:41:51 -07:00
Jason Kulatunga e6cd3ee3c6 Update TESTERS.md 2025-01-04 17:35:41 -07:00
Jason Kulatunga df6a4cef59 Update TROUBLESHOOTING_INFLUXDB.md 2025-01-04 17:01:29 -07:00
Jason Kulatunga 8cf7d64da7 Merge pull request #684 from enoch85/patch-1
Add info about rootless Docker
2025-01-04 15:29:20 -07:00
Jason Kulatunga 3de12cd739 Merge branch 'master' into override-config-with-env-variables 2025-01-04 15:26:10 -07:00
Jason Kulatunga affe05e145 Merge pull request #725 from pabsi/706-add-wait-time-between-checks-fix-unit
Issue 706: Fix time unit
2024-11-26 09:48:53 -05:00
Pablo Garcia 9ad96e6d37 Change to time.Seconds 2024-11-26 15:13:44 +01:00
Pablo Garcia 85d98316f3 Issue 706: Fix time unit 2024-11-26 10:46:27 +01:00
Jason Kulatunga 0641b5e79d Merge pull request #710 from pabsi/706-add-wait-time-between-checks
Add a wait between disks checks
2024-11-22 07:57:13 -05:00
Pablo Garcia Alvarez c168e1e9fc Add check for the wait 2024-11-11 22:07:57 +01:00
Pablo Garcia 56a9454730 Add a wait between disks checks 2024-11-07 11:54:46 +01:00
Martin Kleine a783604c4e Feature: Use automatic binding of env variables 2024-10-15 00:18:54 +02:00
Martin Kleine 604dcf355c feat: Ability to override commands args
In order to override the arguments which are used e.g. to call smartctl, they need to be bind to the respective environment variable.
2024-10-15 00:18:54 +02:00
Jason Kulatunga 57dc547265 fixing github actions. 2024-09-20 11:24:56 -04:00
Jason Kulatunga e0fe17afbf Merge pull request #686 from nicjohnson145/feat--device-allowlist
feat: create allow-list for filtering down devices to only a subset
2024-09-20 11:22:43 -04:00
Nic Johnson c9429c61b2 feat: create allow-list for filtering down devices to only a subset 2024-09-11 23:12:00 -05:00
Daniel Hansson 394ac0af2c Add info about rootless Docker
This avoids session being killed when running rootless.
2024-09-09 21:33:59 +02:00
Jason Kulatunga 48feee51d0 Merge pull request #672 from Hudater/master
Updated containrrr/shoutrrr from v0.7.1 to v0.8.0
2024-09-08 10:27:22 +09:00
Harshit Mani Tripathi d4fb7786d2 reverted accidental bump of spf13/viper from v0.14.0 to v0.15.0 2024-08-04 18:59:37 +05:30
Harshit Mani Tripathi c316f996c6 updated containrrr/shoutrrr from v0.7.1 to v0.8.0 2024-08-04 18:48:23 +05:30
Jason Kulatunga 49108bd1ef Merge pull request #634 from bauzer714/addDeviceHoursSetting
Create a setting for user to indicate humanized or hours on dashboard/device detail
2024-07-25 13:33:57 -07:00
Jason Kulatunga 0dafb65c5f Merge branch 'master' into addDeviceHoursSetting 2024-07-25 13:29:29 -07:00
Brice Bauer c5943a1ca4 Adjust null input response, and tests 2024-07-25 15:40:28 -04:00
Brice Bauer a5893f0bf9 Add tests for DeviceHoursPipe 2024-07-22 14:02:27 -04:00
Brice Bauer 142fe06df1 Move powered_on_hours_unit to a new migration id 2024-07-22 08:37:35 -04:00
Jason Kulatunga 8b7ddd3042 Merge pull request #644 from luomie/patch-1
fix example Shoutrrr discord notification url structure
2024-07-18 20:14:31 -07:00
Jason Kulatunga db57281557 Merge pull request #666 from phcreery/patch-1
Update INSTALL_HUB_SPOKE.md
2024-07-17 18:48:37 -07:00
Peyton Creery 5a5877b729 Update INSTALL_HUB_SPOKE.md 2024-07-17 20:14:26 -05:00
luomie 0a89c2bab3 fix Shoutrrr discord notification url structure 2024-05-19 23:13:05 +02:00
Brice Bauer a18e2842ac Update db migration description to match setting possible values 2024-05-08 08:43:25 -04:00
Brice Bauer 806f7c64a0 Add pipe and implement to dashboard/device component 2024-05-08 08:30:49 -04:00
Brice Bauer 8fa32c6dd7 Add DB Migration and config/settings 2024-05-07 16:45:09 -04:00
packagrio-bot 5e6ab2290b (v0.8.1) Automated packaging of release by Packagr 2024-04-08 04:48:11 +00:00
Jason Kulatunga 67c0af9f59 fix amd64 s6_arch. 2024-04-05 14:11:11 -07:00
Jason Kulatunga 55565e509d Merge pull request #625 from AnalogJ/cron_fixes
fixing cron in #602
2024-04-05 14:04:39 -07:00
Jason Kulatunga f74d9c108a fixing cron in #602
Updated s6overlay to v3
Note:  xz-utils was added as a requirement for s6-overlay (using safe 5.4.1 instead of compromised 5.6.x versions)
2024-04-05 10:01:04 -07:00
76 changed files with 1093 additions and 846 deletions
+7 -6
View File
@@ -13,7 +13,7 @@ jobs:
run: |
make binary-frontend-test-coverage
- name: Upload coverage
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: coverage
path: ${{ github.workspace }}/webapp/frontend/coverage/lcov.info
@@ -49,7 +49,7 @@ jobs:
run: |
make binary-clean binary-test-coverage
- name: Upload coverage
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: coverage
path: ${{ github.workspace }}/coverage.txt
@@ -64,12 +64,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v2
- name: Download coverage reports
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: coverage
- name: Upload coverage reports
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ github.workspace }}/coverage.txt,${{ github.workspace }}/lcov.info
flags: unittests
fail_ci_if_error: true
@@ -106,9 +107,9 @@ jobs:
run: |
make binary-clean binary-all
- name: Archive
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: binaries.zip
name: binaries-${{ matrix.cfg.on }}-${{ matrix.cfg.goos }}-${{ matrix.cfg.goarch }}-${{ matrix.cfg.goarm || 'na' }}.zip
path: |
scrutiny-web-*
scrutiny-collector-metrics-*
+9 -3
View File
@@ -46,7 +46,9 @@ jobs:
latest=false
tags: |
type=ref,enable=true,event=branch,suffix=-collector
type=ref,enable=true,event=tag,suffix=-collector
type=semver,pattern=v{{major}}.{{minor}}.{{patch}},suffix=-collector
type=semver,pattern=v{{major}}.{{minor}},suffix=-collector
type=semver,pattern=v{{major}},suffix=-collector
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
@@ -99,7 +101,9 @@ jobs:
latest=false
tags: |
type=ref,enable=true,event=branch,suffix=-web
type=ref,enable=true,event=tag,suffix=-web
type=semver,pattern=v{{major}}.{{minor}}.{{patch}},suffix=-web
type=semver,pattern=v{{major}}.{{minor}},suffix=-web
type=semver,pattern=v{{major}},suffix=-web
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
@@ -148,7 +152,9 @@ jobs:
with:
tags: |
type=ref,enable=true,event=branch,suffix=-omnibus
type=ref,enable=true,event=tag,suffix=-omnibus
type=semver,pattern=v{{major}}.{{minor}}.{{patch}},suffix=-omnibus
type=semver,pattern=v{{major}}.{{minor}},suffix=-omnibus
type=semver,pattern=v{{major}},suffix=-omnibus
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
-34
View File
@@ -1,34 +0,0 @@
# compiles angular frontend and attaches it to the latest release.
name: Release Frontend
on:
release:
# Only use the types keyword to narrow down the activity types that will trigger your workflow.
types: [published]
jobs:
release-frontend:
name: Release Frontend
runs-on: ubuntu-latest
container: node:lts-slim
steps:
- name: Checkout
uses: actions/checkout@v2
with:
ref: ${{github.event.release.tag_name}}
- name: "Generate frontend version information"
run: "cd webapp/frontend && ./git.version.sh"
- name: Build Frontend
run: |
apt-get update && apt-get install -y make
make binary-frontend
tar -czf scrutiny-web-frontend.tar.gz dist
- name: Upload Frontend Asset
id: upload-release-asset3
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.SCRUTINY_GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps
asset_path: './scrutiny-web-frontend.tar.gz'
asset_name: scrutiny-web-frontend.tar.gz
asset_content_type: application/gzip
+45 -14
View File
@@ -39,7 +39,7 @@ jobs:
add-apt-repository ppa:git-core/ppa && apt-get update && apt-get install -y git
git --version
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Bump version
@@ -61,7 +61,7 @@ jobs:
with:
version_metadata_path: ${{ github.event.inputs.version_metadata_path }}
- name: Upload workspace
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: workspace
path: ${{ github.workspace }}/**/*
@@ -91,36 +91,66 @@ jobs:
- { on: windows-latest, goos: windows, goarch: arm64 }
steps:
- name: Download workspace
uses: actions/download-artifact@v3
uses: actions/download-artifact@v7
with:
name: workspace
- uses: actions/setup-go@v3
- uses: actions/setup-go@v6
with:
go-version: '1.20.1' # The Go version to download (if necessary) and use.
- name: Build Binaries
run: |
make binary-clean binary-all
- name: Archive
uses: actions/upload-artifact@v2
- name: Upload artifacts
uses: actions/upload-artifact@v6
with:
name: binaries.zip
name: scrutiny-${{ matrix.cfg.goos }}-${{ matrix.cfg.goarch }}${{ case(matrix.cfg.goarm != '', format('-{0}', matrix.cfg.goarm), '') }}.zip
path: |
scrutiny-web-*
scrutiny-collector-metrics-*
scrutiny-web-${{ matrix.cfg.goos }}-${{ matrix.cfg.goarch }}${{ case(matrix.cfg.goarm != '', format('-{0}', matrix.cfg.goarm), '') }}${{ case(matrix.cfg.goos == 'windows', '.exe', '') }}
scrutiny-collector-metrics-${{ matrix.cfg.goos }}-${{ matrix.cfg.goarch }}${{ case(matrix.cfg.goarm != '', format('-{0}', matrix.cfg.goarm), '') }}${{ case(matrix.cfg.goos == 'windows', '.exe', '') }}
build_frontend:
name: Build Frontend
needs: release
runs-on: ubuntu-latest
container: node:lts-slim
steps:
- name: Download workspace
uses: actions/download-artifact@v7
with:
name: workspace
- name: "Generate frontend version information"
run: "cd webapp/frontend && chmod +x git.version.sh && ./git.version.sh"
- name: Build Frontend
run: |
apt-get update && apt-get install -y make
make binary-frontend
tar -czf scrutiny-web-frontend.tar.gz dist
- name: Upload artifacts
uses: actions/upload-artifact@v6
with:
name: scrutiny-web-frontend.zip
path: scrutiny-web-frontend.tar.gz
release-publish:
name: Publish Release
needs: build
needs:
- build
- build_frontend
runs-on: ubuntu-latest
steps:
- name: Download workspace
uses: actions/download-artifact@v3
uses: actions/download-artifact@v7
with:
name: workspace
name: ./
- name: Download binaries
uses: actions/download-artifact@v3
uses: actions/download-artifact@v7
with:
name: binaries.zip
merge-multiple: true
pattern: scrutiny-*.zip
- name: Download frontend
uses: actions/download-artifact@v7
with:
name: scrutiny-web-frontend.zip
- name: List
shell: bash
run: |
@@ -144,6 +174,7 @@ jobs:
scrutiny-collector-metrics-linux-arm64
scrutiny-collector-metrics-windows-amd64.exe
scrutiny-collector-metrics-windows-arm64.exe
scrutiny-web-frontend.tar.gz
scrutiny-web-darwin-amd64
scrutiny-web-darwin-arm64
scrutiny-web-freebsd-amd64
+73
View File
@@ -0,0 +1,73 @@
# AI Usage Policy
scrutiny has strict rules for AI usage:
- **All AI usage in any form must be disclosed.** You must state
the tool you used (e.g. Claude Code, Cursor, Amp) along with
the extent that the work was AI-assisted.
- **Pull requests created in any way by AI can only be for accepted issues.**
Drive-by pull requests that do not reference an accepted issue will be
closed. If AI isn't disclosed but a maintainer suspects its use, the
PR will be closed. If you want to share code for a non-accepted issue,
open a discussion or attach it to an existing discussion.
- **Pull requests created by AI must have been fully verified with
human use.** AI must not create hypothetically correct code that
hasn't been tested. Importantly, you must not allow AI to write
code for platforms or environments you don't have access to manually
test on.
- **Issues and discussions can use AI assistance but must have a full
human-in-the-loop.** This means that any content generated with AI
must have been reviewed _and edited_ by a human before submission.
AI is very good at being overly verbose and including noise that
distracts from the main point. Humans must do their research and
trim this down.
- **No AI-generated media is allowed (art, images, videos, audio, etc.).**
Text and code are the only acceptable AI-generated content, per the
other rules in this policy.
- **Bad AI drivers will be banned and ridiculed in public.** You've
been warned. We love to help junior developers learn and grow, but
if you're interested in that then don't use AI, and we'll help you.
I'm sorry that bad AI drivers have ruined this for you.
These rules apply only to outside contributions to scrutiny. Maintainers
and repeat contributors (with explicit permission) are exempt from these
rules and may use AI tools at their discretion; they've proven themselves
trustworthy to apply good judgment.
## There are Humans Here
Please remember that scrutiny is maintained by humans.
Every discussion, issue, and pull request is read and reviewed by
humans (and sometimes machines, too). It is a boundary point at which
people interact with each other and the work done. It is rude and
disrespectful to approach this boundary with low-effort, unqualified
work, since it puts the burden of validation on the maintainer.
In a perfect world, AI would produce high-quality, accurate work
every time. But today, that reality depends on the driver of the AI.
And today, most drivers of AI are just not good enough. So, until either
the people get better, the AI gets better, or both, we have to have
strict rules to protect maintainers.
## AI is Welcome Here
Many maintainers embrace AI tools as a productive tool in their workflow.
As a project, scrutiny welcomes AI as a tool!
**Our reason for the strict AI policy is not due to an anti-AI stance**, but
instead due to the number of highly unqualified people using AI. It's the
people, not the tools, that are the problem.
This section is included to be transparent about the project's usage about
AI for people who may disagree with it, and to address the misconception
that this policy is anti-AI in nature.
# Credit
Adopted from [ghostty's AI policy](https://github.com/ghostty-org/ghostty/blob/1b7a15899ad40fba4ce020f537055d30eaf99ee8/AI_POLICY.md)
+3 -1
View File
@@ -1,5 +1,7 @@
# Contributing
**Please see our [AI policy](./AI_POLICY.md).**
The Scrutiny repository is a [monorepo](https://en.wikipedia.org/wiki/Monorepo) containing source code for:
- Scrutiny Backend Server (API)
- Scrutiny Frontend Angular SPA
@@ -161,7 +163,7 @@ docker cp scrutiny:/tmp/web.log web.log
# Docker Development
```
docker build -f docker/Dockerfile . -t chcr.io/analogj/scrutiny:master-omnibus
docker build -f docker/Dockerfile . -t ghcr.io/analogj/scrutiny:master-omnibus
docker run -it --rm -p 8080:8080 \
-v /run/udev:/run/udev:ro \
--cap-add SYS_RAWIO \
+6 -5
View File
@@ -72,7 +72,7 @@ If you're using Docker, getting started is as simple as running the following co
> See [docker/example.omnibus.docker-compose.yml](https://github.com/AnalogJ/scrutiny/blob/master/docker/example.omnibus.docker-compose.yml) for a docker-compose file.
```bash
docker run -it --rm -p 8080:8080 -p 8086:8086 \
docker run -p 8080:8080 -p 8086:8086 --restart unless-stopped \
-v `pwd`/scrutiny:/opt/scrutiny/config \
-v `pwd`/influxdb2:/opt/scrutiny/influxdb \
-v /run/udev:/run/udev:ro \
@@ -103,17 +103,17 @@ other Docker images:
> See [docker/example.hubspoke.docker-compose.yml](https://github.com/AnalogJ/scrutiny/blob/master/docker/example.hubspoke.docker-compose.yml) for a docker-compose file.
```bash
docker run --rm -p 8086:8086 \
docker run -p 8086:8086 --restart unless-stopped \
-v `pwd`/influxdb2:/var/lib/influxdb2 \
--name scrutiny-influxdb \
influxdb:2.2
docker run --rm -p 8080:8080 \
docker run -p 8080:8080 --restart unless-stopped \
-v `pwd`/scrutiny:/opt/scrutiny/config \
--name scrutiny-web \
ghcr.io/analogj/scrutiny:master-web
docker run --rm \
docker run --restart unless-stopped \
-v /run/udev:/run/udev:ro \
--cap-add SYS_RAWIO \
--device=/dev/sda \
@@ -265,7 +265,8 @@ We use SemVer for versioning. For the versions available, see the tags on this r
# Authors
Jason Kulatunga - Initial Development - @AnalogJ
* Jason Kulatunga - Initial Development - [@AnalogJ](https://github.com/AnalogJ/)
* Aram Akhavan - Maintenence - [@kaysond](https://github.com/kaysond/)
# Licenses
+10 -7
View File
@@ -4,6 +4,12 @@ import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"strings"
"time"
"github.com/analogj/scrutiny/collector/pkg/common/shell"
"github.com/analogj/scrutiny/collector/pkg/config"
"github.com/analogj/scrutiny/collector/pkg/detect"
@@ -11,10 +17,6 @@ import (
"github.com/analogj/scrutiny/collector/pkg/models"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
"net/url"
"os"
"os/exec"
"strings"
)
type MetricsCollector struct {
@@ -90,8 +92,9 @@ func (mc *MetricsCollector) Run() error {
//go mc.Collect(&wg, device.WWN, device.DeviceName, device.DeviceType)
mc.Collect(device.WWN, device.DeviceName, device.DeviceType)
// TODO: we may need to sleep for between each call to smartctl -a
//time.Sleep(30 * time.Millisecond)
if mc.config.GetInt("commands.metrics_smartctl_wait") > 0 {
time.Sleep(time.Duration(mc.config.GetInt("commands.metrics_smartctl_wait")) * time.Second)
}
}
//mc.logger.Infoln("Main: Waiting for workers to finish")
@@ -113,7 +116,7 @@ func (mc *MetricsCollector) Validate() error {
return nil
}
//func (mc *MetricsCollector) Collect(wg *sync.WaitGroup, deviceWWN string, deviceName string, deviceType string) {
// func (mc *MetricsCollector) Collect(wg *sync.WaitGroup, deviceWWN string, deviceName string, deviceType string) {
func (mc *MetricsCollector) Collect(deviceWWN string, deviceName string, deviceType string) {
//defer wg.Done()
if len(deviceWWN) == 0 {
+24 -1
View File
@@ -20,7 +20,7 @@ import (
type configuration struct {
*viper.Viper
deviceOverrides []models.ScanOverride
deviceOverrides []models.ScanOverride
}
//Viper uses the following precedence order. Each item takes precedence over the item below it:
@@ -47,9 +47,17 @@ func (c *configuration) Init() error {
c.SetDefault("commands.metrics_scan_args", "--scan --json")
c.SetDefault("commands.metrics_info_args", "--info --json")
c.SetDefault("commands.metrics_smart_args", "--xall --json")
c.SetDefault("commands.metrics_smartctl_wait", 0)
//configure env variable parsing.
c.SetEnvPrefix("COLLECTOR")
c.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
c.AutomaticEnv()
//c.SetDefault("collect.short.command", "-a -o on -S on")
c.SetDefault("allow_listed_devices", []string{})
//if you want to load a non-standard location system config file (~/drawbridge.yml), use ReadConfig
c.SetConfigType("yaml")
//c.SetConfigName("drawbridge")
@@ -186,3 +194,18 @@ func (c *configuration) GetCommandMetricsSmartArgs(deviceName string) string {
}
return c.GetString("commands.metrics_smart_args")
}
func (c *configuration) IsAllowlistedDevice(deviceName string) bool {
allowList := c.GetStringSlice("allow_listed_devices")
if len(allowList) == 0 {
return true
}
for _, item := range allowList {
if item == deviceName {
return true
}
}
return false
}
+26
View File
@@ -144,3 +144,29 @@ func TestConfiguration_OverrideDeviceCommands_MetricsInfoArgs(t *testing.T) {
require.Equal(t, "--info --json", testConfig.GetCommandMetricsInfoArgs("/dev/sdb"))
//require.Equal(t, []models.ScanOverride{{Device: "/dev/sda", DeviceType: nil, Commands: {MetricsInfoArgs: "--info --json -T "}}}, scanOverrides)
}
func TestConfiguration_DeviceAllowList(t *testing.T) {
t.Parallel()
t.Run("present", func(t *testing.T) {
testConfig, err := config.Create()
require.NoError(t, err)
require.NoError(t, testConfig.ReadConfig(path.Join("testdata", "allow_listed_devices_present.yaml")))
require.True(t, testConfig.IsAllowlistedDevice("/dev/sda"), "/dev/sda should be allow listed")
require.False(t, testConfig.IsAllowlistedDevice("/dev/sdc"), "/dev/sda should not be allow listed")
})
t.Run("missing", func(t *testing.T) {
testConfig, err := config.Create()
require.NoError(t, err)
// Really just any other config where the key is full missing
require.NoError(t, testConfig.ReadConfig(path.Join("testdata", "override_device_commands.yaml")))
// Anything should be allow listed if the key isnt there
require.True(t, testConfig.IsAllowlistedDevice("/dev/sda"), "/dev/sda should be allow listed")
require.True(t, testConfig.IsAllowlistedDevice("/dev/sdc"), "/dev/sda should be allow listed")
})
}
+2
View File
@@ -25,4 +25,6 @@ type Interface interface {
GetDeviceOverrides() []models.ScanOverride
GetCommandMetricsInfoArgs(deviceName string) string
GetCommandMetricsSmartArgs(deviceName string) string
IsAllowlistedDevice(deviceName string) bool
}
+14
View File
@@ -175,6 +175,20 @@ func (mr *MockInterfaceMockRecorder) Init() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockInterface)(nil).Init))
}
// IsAllowlistedDevice mocks base method.
func (m *MockInterface) IsAllowlistedDevice(deviceName string) bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsAllowlistedDevice", deviceName)
ret0, _ := ret[0].(bool)
return ret0
}
// IsAllowlistedDevice indicates an expected call of IsAllowlistedDevice.
func (mr *MockInterfaceMockRecorder) IsAllowlistedDevice(deviceName interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsAllowlistedDevice", reflect.TypeOf((*MockInterface)(nil).IsAllowlistedDevice), deviceName)
}
// IsSet mocks base method.
func (m *MockInterface) IsSet(key string) bool {
m.ctrl.T.Helper()
@@ -0,0 +1,3 @@
allow_listed_devices:
- /dev/sda
- /dev/sdb
+5
View File
@@ -124,6 +124,11 @@ func (d *Detect) TransformDetectedDevices(detectedDeviceConns models.Scan) []mod
deviceFile := strings.ToLower(scannedDevice.Name)
// If the user has defined a device allow list, and this device isnt there, then ignore it
if !d.Config.IsAllowlistedDevice(deviceFile) {
continue
}
detectedDevice := models.Device{
HostId: d.Config.GetString("host.id"),
DeviceType: scannedDevice.Type,
+48
View File
@@ -24,6 +24,7 @@ func TestDetect_SmartctlScan(t *testing.T) {
fakeConfig.EXPECT().GetDeviceOverrides().AnyTimes().Return([]models.ScanOverride{})
fakeConfig.EXPECT().GetString("commands.metrics_smartctl_bin").AnyTimes().Return("smartctl")
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeConfig.EXPECT().IsAllowlistedDevice(gomock.Any()).AnyTimes().Return(true)
fakeShell := mock_shell.NewMockInterface(mockCtrl)
testScanResults, err := os.ReadFile("testdata/smartctl_scan_simple.json")
@@ -53,6 +54,7 @@ func TestDetect_SmartctlScan_Megaraid(t *testing.T) {
fakeConfig.EXPECT().GetDeviceOverrides().AnyTimes().Return([]models.ScanOverride{})
fakeConfig.EXPECT().GetString("commands.metrics_smartctl_bin").AnyTimes().Return("smartctl")
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeConfig.EXPECT().IsAllowlistedDevice(gomock.Any()).AnyTimes().Return(true)
fakeShell := mock_shell.NewMockInterface(mockCtrl)
testScanResults, err := os.ReadFile("testdata/smartctl_scan_megaraid.json")
@@ -85,6 +87,7 @@ func TestDetect_SmartctlScan_Nvme(t *testing.T) {
fakeConfig.EXPECT().GetDeviceOverrides().AnyTimes().Return([]models.ScanOverride{})
fakeConfig.EXPECT().GetString("commands.metrics_smartctl_bin").AnyTimes().Return("smartctl")
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeConfig.EXPECT().IsAllowlistedDevice(gomock.Any()).AnyTimes().Return(true)
fakeShell := mock_shell.NewMockInterface(mockCtrl)
testScanResults, err := os.ReadFile("testdata/smartctl_scan_nvme.json")
@@ -116,6 +119,7 @@ func TestDetect_TransformDetectedDevices_Empty(t *testing.T) {
fakeConfig.EXPECT().GetDeviceOverrides().AnyTimes().Return([]models.ScanOverride{})
fakeConfig.EXPECT().GetString("commands.metrics_smartctl_bin").AnyTimes().Return("smartctl")
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeConfig.EXPECT().IsAllowlistedDevice(gomock.Any()).AnyTimes().Return(true)
detectedDevices := models.Scan{
Devices: []models.ScanDevice{
@@ -149,6 +153,7 @@ func TestDetect_TransformDetectedDevices_Ignore(t *testing.T) {
fakeConfig.EXPECT().GetDeviceOverrides().AnyTimes().Return([]models.ScanOverride{{Device: "/dev/sda", DeviceType: nil, Ignore: true}})
fakeConfig.EXPECT().GetString("commands.metrics_smartctl_bin").AnyTimes().Return("smartctl")
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeConfig.EXPECT().IsAllowlistedDevice(gomock.Any()).AnyTimes().Return(true)
detectedDevices := models.Scan{
Devices: []models.ScanDevice{
@@ -180,6 +185,7 @@ func TestDetect_TransformDetectedDevices_Raid(t *testing.T) {
fakeConfig.EXPECT().GetString("host.id").AnyTimes().Return("")
fakeConfig.EXPECT().GetString("commands.metrics_smartctl_bin").AnyTimes().Return("smartctl")
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeConfig.EXPECT().IsAllowlistedDevice(gomock.Any()).AnyTimes().Return(true)
fakeConfig.EXPECT().GetDeviceOverrides().AnyTimes().Return([]models.ScanOverride{
{
Device: "/dev/bus/0",
@@ -223,6 +229,7 @@ func TestDetect_TransformDetectedDevices_Simple(t *testing.T) {
fakeConfig.EXPECT().GetString("commands.metrics_smartctl_bin").AnyTimes().Return("smartctl")
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeConfig.EXPECT().GetDeviceOverrides().AnyTimes().Return([]models.ScanOverride{{Device: "/dev/sda", DeviceType: []string{"sat+megaraid"}}})
fakeConfig.EXPECT().IsAllowlistedDevice(gomock.Any()).AnyTimes().Return(true)
detectedDevices := models.Scan{
Devices: []models.ScanDevice{
{
@@ -256,6 +263,7 @@ func TestDetect_TransformDetectedDevices_WithoutDeviceTypeOverride(t *testing.T)
fakeConfig.EXPECT().GetString("commands.metrics_smartctl_bin").AnyTimes().Return("smartctl")
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeConfig.EXPECT().GetDeviceOverrides().AnyTimes().Return([]models.ScanOverride{{Device: "/dev/sda"}})
fakeConfig.EXPECT().IsAllowlistedDevice(gomock.Any()).AnyTimes().Return(true)
detectedDevices := models.Scan{
Devices: []models.ScanDevice{
{
@@ -302,6 +310,46 @@ func TestDetect_TransformDetectedDevices_WhenDeviceNotDetected(t *testing.T) {
require.Equal(t, "ata", transformedDevices[0].DeviceType)
}
func TestDetect_TransformDetectedDevices_AllowListFilters(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
fakeConfig := mock_config.NewMockInterface(mockCtrl)
fakeConfig.EXPECT().GetString("host.id").AnyTimes().Return("")
fakeConfig.EXPECT().GetString("commands.metrics_smartctl_bin").AnyTimes().Return("smartctl")
fakeConfig.EXPECT().GetString("commands.metrics_scan_args").AnyTimes().Return("--scan --json")
fakeConfig.EXPECT().GetDeviceOverrides().AnyTimes().Return([]models.ScanOverride{{Device: "/dev/sda", DeviceType: []string{"sat+megaraid"}}})
fakeConfig.EXPECT().IsAllowlistedDevice("/dev/sda").Return(true)
fakeConfig.EXPECT().IsAllowlistedDevice("/dev/sdb").Return(false)
detectedDevices := models.Scan{
Devices: []models.ScanDevice{
{
Name: "/dev/sda",
InfoName: "/dev/sda",
Protocol: "ata",
Type: "ata",
},
{
Name: "/dev/sdb",
InfoName: "/dev/sdb",
Protocol: "ata",
Type: "ata",
},
},
}
d := detect.Detect{
Config: fakeConfig,
}
// test
transformedDevices := d.TransformDetectedDevices(detectedDevices)
// assert
require.Equal(t, 1, len(transformedDevices))
require.Equal(t, "sda", transformedDevices[0].DeviceName)
}
func TestDetect_SmartCtlInfo(t *testing.T) {
t.Run("should report nvme info", func(t *testing.T) {
ctrl := gomock.NewController(t)
+16 -10
View File
@@ -30,8 +30,9 @@ EXPOSE 8080
WORKDIR /opt/scrutiny
ENV PATH="/opt/scrutiny/bin:${PATH}"
ENV INFLUXD_CONFIG_PATH=/opt/scrutiny/influxdb
ENV S6VER="1.21.8.0"
ENV S6VER="3.1.6.2"
ENV INFLUXVER="2.2.0"
ENV S6_SERVICES_READYTIME=1000
SHELL ["/usr/bin/sh", "-c"]
RUN apt-get update && DEBIAN_FRONTEND=noninteractive \
@@ -41,20 +42,22 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive \
curl \
smartmontools \
tzdata \
procps \
xz-utils \
&& rm -rf /var/lib/apt/lists/* \
&& update-ca-certificates \
&& case ${TARGETARCH} in \
"amd64") S6_ARCH=amd64 ;; \
"amd64") S6_ARCH=x86_64 ;; \
"arm64") S6_ARCH=aarch64 ;; \
esac \
&& curl https://github.com/just-containers/s6-overlay/releases/download/v${S6VER}/s6-overlay-${S6_ARCH}.tar.gz -L -s --output /tmp/s6-overlay-${S6_ARCH}.tar.gz \
&& tar xzf /tmp/s6-overlay-${S6_ARCH}.tar.gz -C / \
&& rm -rf /tmp/s6-overlay-${S6_ARCH}.tar.gz \
&& curl -L https://dl.influxdata.com/influxdb/releases/influxdb2-${INFLUXVER}-${TARGETARCH}.deb --output /tmp/influxdb2-${INFLUXVER}-${TARGETARCH}.deb \
&& ln -s /usr/bin/false /bin/false \
&& ln -s /usr/bin/bash /bin/bash \
&& curl https://github.com/just-containers/s6-overlay/releases/download/v${S6VER}/s6-overlay-noarch.tar.xz -L -s --output /tmp/s6-overlay-noarch.tar.xz \
&& tar -Jxpf /tmp/s6-overlay-noarch.tar.xz -C / \
&& rm -rf /tmp/s6-overlay-noarch.tar.xz \
&& curl https://github.com/just-containers/s6-overlay/releases/download/v${S6VER}/s6-overlay-${S6_ARCH}.tar.xz -L -s --output /tmp/s6-overlay-${S6_ARCH}.tar.xz \
&& tar -Jxpf /tmp/s6-overlay-${S6_ARCH}.tar.xz -C / \
&& rm -rf /tmp/s6-overlay-${S6_ARCH}.tar.xz
RUN curl -L https://dl.influxdata.com/influxdb/releases/influxdb2-${INFLUXVER}-${TARGETARCH}.deb --output /tmp/influxdb2-${INFLUXVER}-${TARGETARCH}.deb \
&& dpkg -i --force-all /tmp/influxdb2-${INFLUXVER}-${TARGETARCH}.deb \
&& rm -f /bin/bash \
&& rm -rf /tmp/influxdb2-${INFLUXVER}-${TARGETARCH}.deb
COPY /rootfs /
@@ -66,6 +69,9 @@ RUN chmod 0644 /etc/cron.d/scrutiny && \
rm -f /etc/cron.daily/* && \
mkdir -p /opt/scrutiny/web && \
mkdir -p /opt/scrutiny/config && \
chmod -R ugo+rwx /opt/scrutiny/config
chmod -R ugo+rwx /opt/scrutiny/config && \
chmod +x /etc/cont-init.d/* && \
chmod +x /etc/services.d/*/run && \
chmod +x /etc/services.d/*/finish
CMD ["/init"]
@@ -2,6 +2,7 @@ version: '2.4'
services:
influxdb:
restart: unless-stopped
image: influxdb:2.2
ports:
- '8086:8086'
@@ -15,6 +16,7 @@ services:
web:
restart: unless-stopped
image: 'ghcr.io/analogj/scrutiny:master-web'
ports:
- '8080:8080'
@@ -33,6 +35,7 @@ services:
start_period: 10s
collector:
restart: unless-stopped
image: 'ghcr.io/analogj/scrutiny:master-collector'
cap_add:
- SYS_RAWIO
@@ -41,6 +44,9 @@ services:
environment:
COLLECTOR_API_ENDPOINT: 'http://web:8080'
COLLECTOR_HOST_ID: 'scrutiny-collector-hostname'
# If true forces the collector to run on startup (cron will be started after the collector completes)
# see: https://github.com/AnalogJ/scrutiny/blob/master/docs/TROUBLESHOOTING_DEVICE_COLLECTOR.md#collector-trigger-on-startup
COLLECTOR_RUN_STARTUP: false
depends_on:
web:
condition: service_healthy
@@ -2,6 +2,7 @@ version: '3.5'
services:
scrutiny:
restart: unless-stopped
container_name: scrutiny
image: ghcr.io/analogj/scrutiny:master-omnibus
cap_add:
+4 -3
View File
@@ -59,6 +59,7 @@ networks:
services:
influxdb:
restart: unless-stopped
container_name: influxdb
image: influxdb:2.1-alpine
ports:
@@ -73,11 +74,11 @@ services:
- DOCKER_INFLUXDB_INIT_ORG=homelab
- DOCKER_INFLUXDB_INIT_BUCKET=scrutiny
- DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=your-very-secret-token
restart: unless-stopped
networks:
- monitoring
scrutiny:
restart: unless-stopped
container_name: scrutiny
image: ghcr.io/analogj/scrutiny:master-web
ports:
@@ -94,7 +95,6 @@ services:
- SCRUTINY_NOTIFY_URLS=["http://gotify:80/message?token=a-gotify-token"]
depends_on:
- influxdb
restart: unless-stopped
networks:
- notifications
- monitoring
@@ -121,7 +121,7 @@ apt install smartmontools -y
# 3. Make it exacutable
# 4. List the contents of the library for confirmation
mkdir -p /opt/scrutiny/bin && \
curl -L https://github.com/AnalogJ/scrutiny/releases/download/v0.5.0/scrutiny-collector-metrics-linux-amd64 > /opt/scrutiny/bin/scrutiny-collector-metrics-linux-amd64 && \
curl -L https://github.com/AnalogJ/scrutiny/releases/download/v0.8.1/scrutiny-collector-metrics-linux-amd64 > /opt/scrutiny/bin/scrutiny-collector-metrics-linux-amd64 && \
chmod +x /opt/scrutiny/bin/scrutiny-collector-metrics-linux-amd64 && \
ls -lha /opt/scrutiny/bin
```
@@ -168,6 +168,7 @@ version: "3.4"
services:
collector:
restart: unless-stopped
image: 'ghcr.io/analogj/scrutiny:master-collector'
cap_add:
- SYS_RAWIO
+2 -2
View File
@@ -8,7 +8,7 @@ Thankfully the following users have been gracious enough to test/validate Scruti
| Architecture Name | Binaries | Docker |
| --- | --- | --- |
| linux-amd64 | -- | @feroxy @rshxyz |
| linux-amd64 | @TizzAmmazz | @feroxy @rshxyz |
| linux-arm-5 | -- | |
| linux-arm-6 | -- | |
| linux-arm-7 | @Zorlin | @martini1992 |
@@ -17,4 +17,4 @@ Thankfully the following users have been gracious enough to test/validate Scruti
| macos-amd64 | -- | -- |
| macos-arm64 | -- | -- |
| windows-amd64 | @gabrielv33 | -- |
| windows-arm64 | -- | -- |
| windows-arm64 | -- | -- |
+11 -1
View File
@@ -250,8 +250,9 @@ UPDATE devices SET device_status = null;
### Seagate Drives Failing
As thoroughly discussed in [#255](https://github.com/AnalogJ/scrutiny/issues/255), Seagate (Ironwolf & others) drives are almost always marked as failed by Scrutiny.
As thoroughly discussed in [#255](https://github.com/AnalogJ/scrutiny/issues/255) and [#522](https://github.com/AnalogJ/scrutiny/issues/522), Seagate (Ironwolf & others) drives are almost always marked as failed by Scrutiny.
#### Seek Error Rate & Read Error Rate (#255)
> The `Seek Error Rate` & `Read Error Rate` attribute raw values are typically very high, and the
> normalised values (Current / Worst / Threshold) are usually quite low. Despite this, the numbers in most cases are perfectly OK
>
@@ -286,6 +287,15 @@ other drives, please read the following:
- http://www.users.on.net/~fzabkar/HDD/Seagate_SER_RRER_HEC.html
- https://www.truenas.com/community/threads/seagate-ironwolf-smart-test-raw_read_error_rate-seek_error_rate.68634/
#### Seagate Raw Values are incorrect (#522)
Basically Seagate drives are known to use a custom data format for a number of their SMART attributes. This causes issues with Scrutiny's threshold analysis.
- The workaround is to customize the smartctl command that Scrutiny uses for your drive by [creating a collector.yaml file](https://github.com/AnalogJ/scrutiny/issues/522#issuecomment-1766727988) and "fixing" each attribute
- The **real "fix"** is to make sure your Seagate drive is correctly supported by smartmontools. See this [PR](https://github.com/smartmontools/smartmontools/pull/247)
Sorry for the bad news, but this is a known issue and there's just not much we can do on the Scrutiny side.
## Hub & Spoke model, with multiple Hosts.
![multiple-host-ids image](multiple-host-ids.png)
+8
View File
@@ -17,3 +17,11 @@ So changing from `master-omnibus -> latest` will be the same thing for all inten
> NOTE: Previously, there was a `automated cron build` that ran on the `master` and `beta` branches.
They used to trigger a `nightly` build, even if nothing has changed on the branch. This has a couple of benefits, but one is to
ensure that there's no broken external dependencies in our (unchanged) code. This `nightly` build no longer updates the `master-omnibus` tag.
# Running Docker `rootless`
To avoid that the container(s) restart when you installed Docker as `rootless` you need to isssue the following commands to allow the session to stay alive even after you close your (SSH) sesssion:
`sudo loginctl enable-linger $(whoami)`
`systemctl --user enable docker`
+26
View File
@@ -11,6 +11,32 @@ dependency. It's a dedicated timeseries database, as opposed to the general purp
a bunch of testing and analysis before I made the change. With InfluxDB the memory footprint for Scrutiny (at idle) is ~
100mb, which is still fairly reasonable.
### Data Size
It's surprisingly easy to reach extremely large database sizes, if you don't use downsampling, or you downsample incorrectly.
The growth rate is pretty unintuitive -- see https://github.com/AnalogJ/scrutiny/issues/650#issuecomment-2365174940
> Fasten stores the SMART metrics in a timeseries database (InfluxDB), and automatically downsamples the data on a schedule.
>
> The expectation was that cron would run daily, and there would be:
>
> - 7 daily data points
> - 3 weekly data points
> - 11 monthly data points
> - and infinite yearly data points.
>
> These data points would be for each SMART metric, for each device.
> eg. in one year, (7+3+11)*80ish SMART attributes = 1680 datapoints for one device
>
> If you're running cron every 15 minutes, your browser will instead be attempting to display:
>
> - 96*7 daily data points
> - 3 weekly
> - 11 monthly
>
> so (96*7 + 3 + 11)*80 = 54,880 datapoints for each device 😭
## Installation
InfluxDB is a required dependency for Scrutiny v0.4.0+.
+1
View File
@@ -2,6 +2,7 @@
// SQLite Table(s)
Table Device {
Archived bool
//GORM attributes, see: http://gorm.io/docs/conventions.html
CreatedAt time
UpdatedAt time
+1
View File
@@ -81,6 +81,7 @@ devices:
# metrics_scan_args: '--scan --json' # used to detect devices
# metrics_info_args: '--info --json' # used to determine device unique ID & register device with Scrutiny
# metrics_smart_args: '--xall --json' # used to retrieve smart data for each device.
# metrics_smartctl_wait: 0 # time to wait in seconds between each disk's check
########################################################################################################################
+1 -1
View File
@@ -67,7 +67,7 @@ log:
#notify:
# urls:
# - "discord://token@channel"
# - "discord://token@webhookid"
# - "telegram://token@telegram?channels=channel-1[,channel-2,...]"
# - "pushover://shoutrrr:apiToken@userKey/?priority=1&devices=device1[,device2, ...]"
# - "slack://[botname@]token-a/token-b/token-c"
+10 -11
View File
@@ -4,7 +4,7 @@ go 1.20
require (
github.com/analogj/go-util v0.0.0-20190301173314-5295e364eb14
github.com/containrrr/shoutrrr v0.7.1
github.com/containrrr/shoutrrr v0.8.0
github.com/fatih/color v1.15.0
github.com/gin-gonic/gin v1.6.3
github.com/glebarez/sqlite v1.4.5
@@ -15,7 +15,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0
github.com/samber/lo v1.25.0
github.com/sirupsen/logrus v1.6.0
github.com/spf13/viper v1.14.0
github.com/spf13/viper v1.15.0
github.com/stretchr/testify v1.8.1
github.com/urfave/cli/v2 v2.2.0
golang.org/x/sync v0.1.0
@@ -35,7 +35,7 @@ require (
github.com/go-playground/locales v0.13.0 // indirect
github.com/go-playground/universal-translator v0.17.0 // indirect
github.com/go-playground/validator/v10 v10.2.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect
@@ -46,30 +46,29 @@ require (
github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect
github.com/kvz/logstreamer v0.0.0-20201023134116-02d20f4338f5 // indirect
github.com/leodido/go-urn v1.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.0.5 // indirect
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/afero v1.9.2 // indirect
github.com/spf13/afero v1.9.3 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/ugorji/go/codec v1.1.7 // indirect
golang.org/x/crypto v0.1.0 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
golang.org/x/net v0.1.0 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/term v0.1.0 // indirect
golang.org/x/text v0.4.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.8.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
+26 -629
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/with-contenv bash
#!/command/with-contenv bash
if [ -n "${TZ}" ]
then
+2 -2
View File
@@ -1,4 +1,4 @@
#!/usr/bin/with-contenv bash
#!/command/with-contenv bash
# Cron runs in its own isolated environment (usually using only /etc/environment )
# So when the container starts up, we will do a dump of the runtime environment into a .env file that we
@@ -12,4 +12,4 @@ COLLECTOR_CRON_SCHEDULE=${COLLECTOR_CRON_SCHEDULE:-"0 0 * * *"}
[[ "${COLLECTOR_CRON_SCHEDULE}" == \"*\" || "${COLLECTOR_CRON_SCHEDULE}" == \'*\' ]] && COLLECTOR_CRON_SCHEDULE="${COLLECTOR_CRON_SCHEDULE:1:-1}"
# replace placeholder with correct value
sed -i 's|{COLLECTOR_CRON_SCHEDULE}|'"${COLLECTOR_CRON_SCHEDULE}"'|g' /etc/cron.d/scrutiny
sed -i 's|{COLLECTOR_CRON_SCHEDULE}|'"${COLLECTOR_CRON_SCHEDULE}"'|g' /etc/cron.d/scrutiny
+17 -5
View File
@@ -1,13 +1,25 @@
#!/usr/bin/with-contenv bash
#!/command/with-contenv bash
# ensure not run (successfully) before
if [ -f /tmp/custom-init-performed ]; then
echo 'INFO: custom init already performed'
s6-svc -D /run/service/collector-once # prevent s6 from restarting service
exit 0
fi
echo "waiting for scrutiny service to start"
s6-svwait -u /var/run/s6/services/scrutiny
#tell s6 to only run this script once
s6-svc -O /var/run/s6/services/collector-once
s6-svwait -u /run/service/scrutiny
# wait until scrutiny is "Ready"
until $(curl --output /dev/null --silent --head --fail http://localhost:8080/api/health); do echo "scrutiny api not ready" && sleep 5; done
echo "starting scrutiny collector (run-once mode. subsequent calls will be triggered via cron service)"
/opt/scrutiny/bin/scrutiny-collector-metrics run
# prevent script's core logic from running again
touch /tmp/custom-init-performed
# prevent s6 from restarting service
s6-svc -D /run/service/collector-once
exit 0
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/execlineb -S0
#!/command/execlineb -S0
echo "cron exiting"
s6-svscanctl -t /var/run/s6/services
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/with-contenv bash
#!/command/with-contenv bash
echo "starting cron"
cron -f -L 15
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/with-contenv bash
#!/command/with-contenv bash
mkdir -p /opt/scrutiny/influxdb/
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/with-contenv bash
#!/command/with-contenv bash
echo "waiting for influxdb"
until $(curl --output /dev/null --silent --head --fail http://localhost:8086/health); do echo "influxdb not ready" && sleep 5; done
+2 -1
View File
@@ -20,12 +20,13 @@ type DeviceRepo interface {
UpdateDevice(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (models.Device, error)
UpdateDeviceStatus(ctx context.Context, wwn string, status pkg.DeviceStatus) (models.Device, error)
GetDeviceDetails(ctx context.Context, wwn string) (models.Device, error)
UpdateDeviceArchived(ctx context.Context, wwn string, archived bool) error
DeleteDevice(ctx context.Context, wwn string) error
SaveSmartAttributes(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (measurements.Smart, error)
GetSmartAttributeHistory(ctx context.Context, wwn string, durationKey string, selectEntries int, selectEntriesOffset int, attributes []string) ([]measurements.Smart, error)
SaveSmartTemperature(ctx context.Context, wwn string, deviceProtocol string, collectorSmartData collector.SmartInfo) error
SaveSmartTemperature(ctx context.Context, wwn string, deviceProtocol string, collectorSmartData collector.SmartInfo, discardSCTTempHistory bool) error
GetSummary(ctx context.Context) (map[string]*models.DeviceSummary, error)
GetSmartTemperatureHistory(ctx context.Context, durationKey string) (map[string][]measurements.SmartTemperature, error)
@@ -5,6 +5,7 @@ import (
"time"
)
// Deprecated: m20220509170100.Device is deprecated, only used by db migrations
type Device struct {
//GORM attributes, see: http://gorm.io/docs/conventions.html
CreatedAt time.Time
@@ -14,9 +15,9 @@ type Device struct {
WWN string `json:"wwn" gorm:"primary_key"`
DeviceName string `json:"device_name"`
DeviceUUID string `json:"device_uuid"`
DeviceSerialID string `json:"device_serial_id"`
DeviceLabel string `json:"device_label"`
DeviceUUID string `json:"device_uuid"`
DeviceSerialID string `json:"device_serial_id"`
DeviceLabel string `json:"device_label"`
Manufacturer string `json:"manufacturer"`
ModelName string `json:"model_name"`
@@ -38,4 +39,3 @@ type Device struct {
// Data set by Scrutiny
DeviceStatus pkg.DeviceStatus `json:"device_status"`
}
@@ -0,0 +1,41 @@
package m20250221084400
import (
"github.com/analogj/scrutiny/webapp/backend/pkg"
"time"
)
type Device struct {
Archived bool `json:"archived"`
//GORM attributes, see: http://gorm.io/docs/conventions.html
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
WWN string `json:"wwn" gorm:"primary_key"`
DeviceName string `json:"device_name"`
DeviceUUID string `json:"device_uuid"`
DeviceSerialID string `json:"device_serial_id"`
DeviceLabel string `json:"device_label"`
Manufacturer string `json:"manufacturer"`
ModelName string `json:"model_name"`
InterfaceType string `json:"interface_type"`
InterfaceSpeed string `json:"interface_speed"`
SerialNumber string `json:"serial_number"`
Firmware string `json:"firmware"`
RotationSpeed int `json:"rotational_speed"`
Capacity int64 `json:"capacity"`
FormFactor string `json:"form_factor"`
SmartSupport bool `json:"smart_support"`
DeviceProtocol string `json:"device_protocol"` //protocol determines which smart attribute types are available (ATA, NVMe, SCSI)
DeviceType string `json:"device_type"` //device type is used for querying with -d/t flag, should only be used by collector.
// User provided metadata
Label string `json:"label"`
HostId string `json:"host_id"`
// Data set by Scrutiny
DeviceStatus pkg.DeviceStatus `json:"device_status"`
}
@@ -52,6 +52,20 @@ func (mr *MockDeviceRepoMockRecorder) Close() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockDeviceRepo)(nil).Close))
}
// UpdateDeviceArchived mocks base method.
func (m *MockDeviceRepo) UpdateDeviceArchived(ctx context.Context, wwn string, archived bool) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateDeviceArchived", ctx, wwn)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateDeviceArchived indicates an expected call of UpdateDeviceArchived.
func (mr *MockDeviceRepoMockRecorder) UpdateDeviceArchived(ctx, wwn, archived interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDeviceArchived", reflect.TypeOf((*MockDeviceRepo)(nil).UpdateDeviceArchived), ctx, wwn, archived)
}
// DeleteDevice mocks base method.
func (m *MockDeviceRepo) DeleteDevice(ctx context.Context, wwn string) error {
m.ctrl.T.Helper()
@@ -214,17 +228,17 @@ func (mr *MockDeviceRepoMockRecorder) SaveSmartAttributes(ctx, wwn, collectorSma
}
// SaveSmartTemperature mocks base method.
func (m *MockDeviceRepo) SaveSmartTemperature(ctx context.Context, wwn, deviceProtocol string, collectorSmartData collector.SmartInfo) error {
func (m *MockDeviceRepo) SaveSmartTemperature(ctx context.Context, wwn, deviceProtocol string, collectorSmartData collector.SmartInfo, discardSCTTempHistory bool) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SaveSmartTemperature", ctx, wwn, deviceProtocol, collectorSmartData)
ret := m.ctrl.Call(m, "SaveSmartTemperature", ctx, wwn, deviceProtocol, collectorSmartData, discardSCTTempHistory)
ret0, _ := ret[0].(error)
return ret0
}
// SaveSmartTemperature indicates an expected call of SaveSmartTemperature.
func (mr *MockDeviceRepoMockRecorder) SaveSmartTemperature(ctx, wwn, deviceProtocol, collectorSmartData interface{}) *gomock.Call {
func (mr *MockDeviceRepoMockRecorder) SaveSmartTemperature(ctx, wwn, deviceProtocol, collectorSmartData, discardSCTTempHistory interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSmartTemperature", reflect.TypeOf((*MockDeviceRepo)(nil).SaveSmartTemperature), ctx, wwn, deviceProtocol, collectorSmartData)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSmartTemperature", reflect.TypeOf((*MockDeviceRepo)(nil).SaveSmartTemperature), ctx, wwn, deviceProtocol, collectorSmartData, discardSCTTempHistory)
}
// UpdateDevice mocks base method.
@@ -14,7 +14,7 @@ import (
// Device
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//insert device into DB (and update specified columns if device is already registered)
// insert device into DB (and update specified columns if device is already registered)
// update device fields that may change: (DeviceType, HostID)
func (sr *scrutinyRepository) RegisterDevice(ctx context.Context, dev models.Device) error {
if err := sr.gormClient.WithContext(ctx).Clauses(clause.OnConflict{
@@ -51,7 +51,7 @@ func (sr *scrutinyRepository) UpdateDevice(ctx context.Context, wwn string, coll
return device, sr.gormClient.Model(&device).Updates(device).Error
}
//Update Device Status
// Update Device Status
func (sr *scrutinyRepository) UpdateDeviceStatus(ctx context.Context, wwn string, status pkg.DeviceStatus) (models.Device, error) {
var device models.Device
if err := sr.gormClient.WithContext(ctx).Where("wwn = ?", wwn).First(&device).Error; err != nil {
@@ -74,6 +74,16 @@ func (sr *scrutinyRepository) GetDeviceDetails(ctx context.Context, wwn string)
return device, nil
}
// Update Device Archived State
func (sr *scrutinyRepository) UpdateDeviceArchived(ctx context.Context, wwn string, archived bool) error {
var device models.Device
if err := sr.gormClient.WithContext(ctx).Where("wwn = ?", wwn).First(&device).Error; err != nil {
return fmt.Errorf("Could not get device from DB: %v", err)
}
return sr.gormClient.Model(&device).Where("wwn = ?", wwn).Update("archived", archived).Error
}
func (sr *scrutinyRepository) DeleteDevice(ctx context.Context, wwn string) error {
if err := sr.gormClient.WithContext(ctx).Where("wwn = ?", wwn).Delete(&models.Device{}).Error; err != nil {
return err
@@ -194,6 +194,9 @@ func (sr *scrutinyRepository) generateSmartAttributesSubquery(wwn string, durati
`|> filter(fn: (r) => r["_measurement"] == "smart" )`,
fmt.Sprintf(`|> filter(fn: (r) => r["device_wwn"] == "%s" )`, wwn),
}
partialQueryStr = append(partialQueryStr, `|> aggregateWindow(every: 1d, fn: last, createEmpty: false)`)
if selectEntries > 0 {
partialQueryStr = append(partialQueryStr, fmt.Sprintf(`|> tail(n: %d, offset: %d)`, selectEntries, selectEntriesOffset))
}
@@ -12,6 +12,7 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220503120000"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220509170100"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220716214900"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20250221084400"
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
@@ -332,7 +333,6 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
SettingDataType: "string",
SettingValueString: "smooth",
},
{
SettingKeyName: "metrics.notify_level",
SettingKeyDescription: "Determines which device status will cause a notification (fail or warn)",
@@ -385,6 +385,45 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
return tx.Create(&defaultSettings).Error
},
},
{
ID: "m20240722082740", // add powered_on_hours_unit setting.
Migrate: func(tx *gorm.DB) error {
//add powered_on_hours_unit setting default.
var defaultSettings = []m20220716214900.Setting{
{
SettingKeyName: "powered_on_hours_unit",
SettingKeyDescription: "Presentation format for device powered on time ('humanize' | 'device_hours')",
SettingDataType: "string",
SettingValueString: "humanize",
},
}
return tx.Create(&defaultSettings).Error
},
},
{
ID: "m20250221084400", // add archived to device data
Migrate: func(tx *gorm.DB) error {
//migrate the device database.
// adding column (archived)
return tx.AutoMigrate(m20250221084400.Device{})
},
},
{
ID: "m20260105083200", // add discard_sct_temp_history setting.
Migrate: func(tx *gorm.DB) error {
//add discard_sct_temp_history setting default.
var defaultSettings = []m20220716214900.Setting{
{
SettingKeyName: "collector.discard_sct_temp_history",
SettingKeyDescription: "Whether to discard SCT Temperature history (true | false)",
SettingDataType: "bool",
SettingValueBool: false,
},
}
return tx.Create(&defaultSettings).Error
},
},
})
if err := m.Migrate(); err != nil {
@@ -421,8 +460,8 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
// helpers
//When adding data to influxdb, an error may be returned if the data point is outside the range of the retention policy.
//This function will ignore retention policy errors, and allow the migration to continue.
// When adding data to influxdb, an error may be returned if the data point is outside the range of the retention policy.
// This function will ignore retention policy errors, and allow the migration to continue.
func ignorePastRetentionPolicyError(err error) error {
var influxDbWriteError *http.Error
if errors.As(err, &influxDbWriteError) {
@@ -3,18 +3,19 @@ package database
import (
"context"
"fmt"
"strings"
"time"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
influxdb2 "github.com/influxdata/influxdb-client-go/v2"
"strings"
"time"
)
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// //////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Temperature Data
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (sr *scrutinyRepository) SaveSmartTemperature(ctx context.Context, wwn string, deviceProtocol string, collectorSmartData collector.SmartInfo) error {
if len(collectorSmartData.AtaSctTemperatureHistory.Table) > 0 {
// //////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (sr *scrutinyRepository) SaveSmartTemperature(ctx context.Context, wwn string, deviceProtocol string, collectorSmartData collector.SmartInfo, discardSCTTempHistory bool) error {
if len(collectorSmartData.AtaSctTemperatureHistory.Table) > 0 && !discardSCTTempHistory {
for ndx, temp := range collectorSmartData.AtaSctTemperatureHistory.Table {
//temp value may be null, we must skip/ignore them. See #393
@@ -22,9 +23,11 @@ func (sr *scrutinyRepository) SaveSmartTemperature(ctx context.Context, wwn stri
continue
}
minutesOffset := collectorSmartData.AtaSctTemperatureHistory.LoggingIntervalMinutes * int64(ndx) * 60
intervalSec := collectorSmartData.AtaSctTemperatureHistory.LoggingIntervalMinutes * 60
datapointTime := collectorSmartData.LocalTime.TimeT - int64(ndx) * intervalSec
alignedDatapointTime := datapointTime - datapointTime % intervalSec
smartTemp := measurements.SmartTemperature{
Date: time.Unix(collectorSmartData.LocalTime.TimeT-minutesOffset, 0),
Date: time.Unix(alignedDatapointTime, 0),
Temp: temp,
}
@@ -39,23 +42,22 @@ func (sr *scrutinyRepository) SaveSmartTemperature(ctx context.Context, wwn stri
return err
}
}
// also add the current temperature.
} else {
smartTemp := measurements.SmartTemperature{
Date: time.Unix(collectorSmartData.LocalTime.TimeT, 0),
Temp: collectorSmartData.Temperature.Current,
}
tags, fields := smartTemp.Flatten()
tags["device_wwn"] = wwn
p := influxdb2.NewPoint("temp",
tags,
fields,
smartTemp.Date)
return sr.influxWriteApi.WritePoint(ctx, p)
}
return nil
// Even if ata_sct_temperature_history is present, also add current temperature. See #824
smartTemp := measurements.SmartTemperature{
Date: time.Unix(collectorSmartData.LocalTime.TimeT, 0),
Temp: collectorSmartData.Temperature.Current,
}
tags, fields := smartTemp.Flatten()
tags["device_wwn"] = wwn
p := influxdb2.NewPoint("temp",
tags,
fields,
smartTemp.Date)
return sr.influxWriteApi.WritePoint(ctx, p)
}
func (sr *scrutinyRepository) GetSmartTemperatureHistory(ctx context.Context, durationKey string) (map[string][]measurements.SmartTemperature, error) {
+1
View File
@@ -14,6 +14,7 @@ type DeviceWrapper struct {
type Device struct {
//GORM attributes, see: http://gorm.io/docs/conventions.html
Archived bool `json:"archived"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
@@ -64,11 +64,27 @@ func NewSmartFromInfluxDB(attrs map[string]interface{}) (*Smart, error) {
for key, val := range attrs {
switch key {
case "temp":
sm.Temp = val.(int64)
temp, tempOk := val.(int64)
if tempOk {
sm.Temp = temp
} else {
log.Printf("unable to parse temp information: %v", val)
}
case "power_on_hours":
sm.PowerOnHours = val.(int64)
powerOn, powerOnOk := val.(int64)
if powerOnOk {
sm.PowerOnHours = powerOn
} else {
log.Printf("unable to parse power_on_hours information: %v", val)
}
case "power_cycle_count":
sm.PowerCycleCount = val.(int64)
powerCycle, powerCycleOk := val.(int64)
if powerCycleOk {
sm.PowerCycleCount = powerCycle
} else {
log.Printf("unable to parse power_cycle_count information: %v", val)
}
default:
// this key is unknown.
if !strings.HasPrefix(key, "attr.") {
+12 -7
View File
@@ -8,13 +8,18 @@ package models
//}
type Settings struct {
Theme string `json:"theme" mapstructure:"theme"`
Layout string `json:"layout" mapstructure:"layout"`
DashboardDisplay string `json:"dashboard_display" mapstructure:"dashboard_display"`
DashboardSort string `json:"dashboard_sort" mapstructure:"dashboard_sort"`
TemperatureUnit string `json:"temperature_unit" mapstructure:"temperature_unit"`
FileSizeSIUnits bool `json:"file_size_si_units" mapstructure:"file_size_si_units"`
LineStroke string `json:"line_stroke" mapstructure:"line_stroke"`
Theme string `json:"theme" mapstructure:"theme"`
Layout string `json:"layout" mapstructure:"layout"`
DashboardDisplay string `json:"dashboard_display" mapstructure:"dashboard_display"`
DashboardSort string `json:"dashboard_sort" mapstructure:"dashboard_sort"`
TemperatureUnit string `json:"temperature_unit" mapstructure:"temperature_unit"`
FileSizeSIUnits bool `json:"file_size_si_units" mapstructure:"file_size_si_units"`
LineStroke string `json:"line_stroke" mapstructure:"line_stroke"`
PoweredOnHoursUnit string `json:"powered_on_hours_unit" mapstructure:"powered_on_hours_unit"`
Collector struct {
DiscardSCTTempHistory bool `json:"discard_sct_temp_history" mapstructure:"discard_sct_temp_history"`
} `json:"collector" mapstructure:"collector"`
Metrics struct {
NotifyLevel int `json:"notify_level" mapstructure:"notify_level"`
+1 -1
View File
@@ -2,4 +2,4 @@ package version
// VERSION is the app-global version string, which will be replaced with a
// new value during packaging
const VERSION = "0.8.0"
const VERSION = "0.8.1"
@@ -0,0 +1,22 @@
package handler
import (
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"net/http"
)
func ArchiveDevice(c *gin.Context) {
logger := c.MustGet("LOGGER").(*logrus.Entry)
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
err := deviceRepo.UpdateDeviceArchived(c, c.Param("wwn"), true)
if err != nil {
logger.Errorln("An error occurred while archiving device", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
@@ -0,0 +1,22 @@
package handler
import (
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"net/http"
)
func UnarchiveDevice(c *gin.Context) {
logger := c.MustGet("LOGGER").(*logrus.Entry)
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
err := deviceRepo.UpdateDeviceArchived(c, c.Param("wwn"), false)
if err != nil {
logger.Errorln("An error occurred while unarchiving device", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
@@ -61,7 +61,7 @@ func UploadDeviceMetrics(c *gin.Context) {
}
// save smart temperature data (ignore failures)
err = deviceRepo.SaveSmartTemperature(c, c.Param("wwn"), updatedDevice.DeviceProtocol, collectorSmartData)
err = deviceRepo.SaveSmartTemperature(c, c.Param("wwn"), updatedDevice.DeviceProtocol, collectorSmartData, appConfig.GetBool(fmt.Sprintf("%s.collector.discard_sct_temp_history", config.DB_USER_SETTINGS_SUBKEY)))
if err != nil {
logger.Errorln("An error occurred while saving smartctl temp data", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
+4 -2
View File
@@ -42,8 +42,10 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
api.GET("/summary/temp", handler.GetDevicesSummaryTempHistory) //used by Dashboard (Temperature history dropdown)
api.POST("/device/:wwn/smart", handler.UploadDeviceMetrics) //used by Collector to upload data
api.POST("/device/:wwn/selftest", handler.UploadDeviceSelfTests)
api.GET("/device/:wwn/details", handler.GetDeviceDetails) //used by Details
api.DELETE("/device/:wwn", handler.DeleteDevice) //used by UI to delete device
api.GET("/device/:wwn/details", handler.GetDeviceDetails) //used by Details
api.POST("/device/:wwn/archive", handler.ArchiveDevice) //used by UI to archive device
api.POST("/device/:wwn/unarchive", handler.UnarchiveDevice) //used by UI to unarchive device
api.DELETE("/device/:wwn", handler.DeleteDevice) //used by UI to delete device
api.GET("/settings", handler.GetSettings) //used to get settings
api.POST("/settings", handler.SaveSettings) //used to save settings
+3
View File
@@ -191,6 +191,7 @@ func (suite *ServerTestSuite) TestUploadDeviceMetricsRoute() {
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("user.metrics.repeat_notifications").Return(true).AnyTimes()
fakeConfig.EXPECT().GetBool("user.collector.discard_sct_temp_history").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
@@ -250,6 +251,7 @@ func (suite *ServerTestSuite) TestPopulateMultiple() {
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("user.metrics.repeat_notifications").Return(true).AnyTimes()
fakeConfig.EXPECT().GetBool("user.collector.discard_sct_temp_history").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
if _, isGithubActions := os.LookupEnv("GITHUB_ACTIONS"); isGithubActions {
@@ -533,6 +535,7 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
fakeConfig.EXPECT().GetString("web.influxdb.org").Return("scrutiny").AnyTimes()
fakeConfig.EXPECT().GetString("web.influxdb.bucket").Return("metrics").AnyTimes()
fakeConfig.EXPECT().GetBool("user.metrics.repeat_notifications").Return(true).AnyTimes()
fakeConfig.EXPECT().GetBool("user.collector.discard_sct_temp_history").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.tls.insecure_skip_verify").Return(false).AnyTimes()
fakeConfig.EXPECT().GetBool("web.influxdb.retention_policy").Return(false).AnyTimes()
fakeConfig.EXPECT().GetStringSlice("notify.urls").AnyTimes().Return([]string{})
@@ -12,6 +12,8 @@ export type TemperatureUnit = 'celsius' | 'fahrenheit'
export type LineStroke = 'smooth' | 'straight' | 'stepline'
export type DevicePoweredOnUnit = 'humanize' | 'device_hours'
export enum MetricsNotifyLevel {
Warn = 1,
@@ -47,9 +49,15 @@ export interface AppConfig {
file_size_si_units?: boolean;
powered_on_hours_unit?: DevicePoweredOnUnit;
line_stroke?: LineStroke;
// Settings from Scrutiny API
collector?: {
discard_sct_temp_history?: boolean
}
metrics?: {
notify_level?: MetricsNotifyLevel
@@ -77,8 +85,13 @@ export const appConfig: AppConfig = {
temperature_unit: 'celsius',
file_size_si_units: false,
powered_on_hours_unit: 'humanize',
line_stroke: 'smooth',
collector: {
discard_sct_temp_history : false,
},
metrics: {
notify_level: MetricsNotifyLevel.Fail,
@@ -1,5 +1,6 @@
// maps to webapp/backend/pkg/models/device.go
export interface DeviceModel {
archived?: boolean;
wwn: string;
device_name?: string;
device_uuid?: string;
@@ -20,7 +20,8 @@ export const sda = {
'device_type': '',
'label': '',
'host_id': '',
'device_status': 0
'device_status': 0,
'archived': false
},
'smart_results': [{
'date': '2021-10-24T23:20:44Z',
@@ -28,7 +28,8 @@ export const summary = {
'device_type': '',
'label': '',
'host_id': '',
'device_status': 0
'device_status': 0,
'archived': false
}
},
'0x5000cca252c859cc': {
@@ -55,7 +56,8 @@ export const summary = {
'device_type': '',
'label': '',
'host_id': '',
'device_status': 0
'device_status': 0,
'archived': false
},
'smart': {
'collector_date': '2020-08-21T22:27:02Z',
@@ -91,7 +93,8 @@ export const summary = {
'device_type': '',
'label': '',
'host_id': '',
'device_status': 0
'device_status': 0,
'archived': false
},
'smart': {
'collector_date': '2020-06-21T00:03:30Z',
@@ -127,7 +130,8 @@ export const summary = {
'device_type': '',
'label': '',
'host_id': '',
'device_status': 0
'device_status': 0,
'archived': false
}
},
'0x5000cca264ec3183': {
@@ -154,7 +158,8 @@ export const summary = {
'device_type': '',
'label': '',
'host_id': 'custom host id',
'device_status': 1
'device_status': 1,
'archived': false
},
'smart': {
'collector_date': '2020-09-13T16:29:23Z',
@@ -574,7 +579,8 @@ export const summary = {
'device_type': '',
'label': '',
'host_id': '',
'device_status': 0
'device_status': 0,
'archived': false
}
},
'0x5002538e40a22954': {
@@ -601,7 +607,8 @@ export const summary = {
'device_type': '',
'label': '',
'host_id': '',
'device_status': 0
'device_status': 0,
'archived': false
},
'smart': {
'collector_date': '2020-06-10T12:01:02Z',
@@ -0,0 +1,11 @@
<h2 mat-dialog-title>Archive {{data.title}}?</h2>
<mat-dialog-content>This will remove the device from Scrutiny dashboard, unless you toggle show archived. <strong>Any data about the device
itself will remain untouched.</strong></mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close>Cancel</button>
<button class="yellow-600" mat-button (click)="onArchiveClick()">
<mat-icon class="icon-size-20 mr-3"
[svgIcon]="'archive'"></mat-icon>
Archive
</button>
</mat-dialog-actions>
@@ -0,0 +1,64 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {DashboardDeviceArchiveDialogComponent} from './dashboard-device-archive-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 {DashboardDeviceArchiveDialogService} from './dashboard-device-archive-dialog.service';
import {of} from 'rxjs';
describe('DashboardDeviceArchiveDialogComponent', () => {
let component: DashboardDeviceArchiveDialogComponent;
let fixture: ComponentFixture<DashboardDeviceArchiveDialogComponent>;
const matDialogRefSpy = jasmine.createSpyObj('MatDialogRef', ['closeDialog', 'close']);
const dashboardDeviceArchiveDialogServiceSpy = jasmine.createSpyObj('DashboardDeviceArchiveDialogService', ['archiveDevice']);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
HttpClientModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
SharedModule,
],
providers: [
{provide: MatDialogRef, useValue: matDialogRefSpy},
{provide: MAT_DIALOG_DATA, useValue: {wwn: 'test-wwn', title: 'my-test-device-title'}},
{provide: DashboardDeviceArchiveDialogService, useValue: dashboardDeviceArchiveDialogServiceSpy}
],
declarations: [DashboardDeviceArchiveDialogComponent]
})
.compileComponents()
}));
beforeEach(() => {
fixture = TestBed.createComponent(DashboardDeviceArchiveDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should close the component if cancel is clicked', () => {
matDialogRefSpy.closeDialog.calls.reset();
matDialogRefSpy.closeDialog()
expect(matDialogRefSpy.closeDialog).toHaveBeenCalled();
});
it('should attempt to archive device if archive is clicked', () => {
dashboardDeviceArchiveDialogServiceSpy.archiveDevice.and.returnValue(of({'success': true}));
component.onArchiveClick()
expect(dashboardDeviceArchiveDialogServiceSpy.archiveDevice).toHaveBeenCalledWith('test-wwn');
expect(dashboardDeviceArchiveDialogServiceSpy.archiveDevice.calls.count())
.withContext('one call')
.toBe(1);
});
});
@@ -0,0 +1,29 @@
import {Component, Inject, OnInit} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
import {DashboardDeviceArchiveDialogService} from 'app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.service';
@Component({
selector: 'app-dashboard-device-archive-dialog',
templateUrl: './dashboard-device-archive-dialog.component.html',
styleUrls: ['./dashboard-device-archive-dialog.component.scss'],
})
export class DashboardDeviceArchiveDialogComponent implements OnInit {
constructor(
public dialogRef: MatDialogRef<DashboardDeviceArchiveDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: {wwn: string, title: string},
private _archiveService: DashboardDeviceArchiveDialogService,
) {
}
ngOnInit(): void {
}
onArchiveClick(): void {
this._archiveService.archiveDevice(this.data.wwn)
.subscribe((data) => {
this.dialogRef.close(data);
});
}
}
@@ -0,0 +1,29 @@
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {SharedModule} from 'app/shared/shared.module';
import {dashboardRoutes} from 'app/modules/dashboard/dashboard.routing';
import {MatDialogModule} from '@angular/material/dialog';
import {DashboardDeviceArchiveDialogComponent} from './dashboard-device-archive-dialog.component';
@NgModule({
declarations: [
DashboardDeviceArchiveDialogComponent
],
imports: [
RouterModule.forChild([]),
RouterModule.forChild(dashboardRoutes),
MatButtonModule,
MatIconModule,
SharedModule,
MatDialogModule
],
exports : [
DashboardDeviceArchiveDialogComponent,
],
providers : []
})
export class DashboardDeviceArchiveDialogModule
{
}
@@ -0,0 +1,38 @@
import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {getBasePath} from 'app/app.routing';
@Injectable({
providedIn: 'root'
})
export class DashboardDeviceArchiveDialogService
{
/**
* Constructor
*
* @param {HttpClient} _httpClient
*/
constructor(
private _httpClient: HttpClient
)
{
}
// -----------------------------------------------------------------------------------------------------
// @ Public methods
// -----------------------------------------------------------------------------------------------------
archiveDevice(wwn: string): Observable<any>
{
return this._httpClient.post( `${getBasePath()}/api/device/${wwn}/archive`, {});
}
unarchiveDevice(wwn: string): Observable<any>
{
return this._httpClient.post( `${getBasePath()}/api/device/${wwn}/unarchive`, {});
}
}
@@ -1,5 +1,7 @@
<div [ngClass]="{ 'border-green': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'passed',
'border-red': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'failed' }"
<div
[ngClass]="{ 'border-green': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'passed',
'border-red': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'failed',
'text-disabled': deviceSummary.device.archived }"
class="relative flex flex-col flex-auto p-6 pr-3 pb-3 bg-card rounded border-l-4 shadow-md overflow-hidden">
<div class="absolute bottom-0 right-0 w-24 h-24 -m-6">
<mat-icon class="icon-size-96 opacity-12 text-green"
@@ -21,6 +23,8 @@
</div>
</div>
<div class="ml-auto" *ngIf="deviceSummary.device">
<mat-icon *ngIf="deviceSummary.device.archived"
[svgIcon]="'archive'"></mat-icon>
<button mat-icon-button
[matMenuTriggerFor]="previousStatementMenu">
<mat-icon [svgIcon]="'more_vert'"></mat-icon>
@@ -33,6 +37,14 @@
<span>View Details</span>
</span>
</a>
<a mat-menu-item
(click)="openArchiveDialog()">
<span class="flex items-center">
<mat-icon class="icon-size-20 mr-3"
[svgIcon]="deviceSummary.device.archived ? 'unarchive':'archive'"></mat-icon>
<span>{{deviceSummary.device.archived ? "Unarchive" : "Archive"}} Device</span>
</span>
</a>
<a mat-menu-item (click)="openDeleteDialog()">
<span class="flex items-center">
<mat-icon class="icon-size-20 mr-3"
@@ -63,7 +75,7 @@
</div>
<div class="flex flex-col mx-6 my-3 xs:w-full">
<div class="font-semibold text-xs text-hint uppercase tracking-wider leading-none">Powered On</div>
<div class="mt-2 font-medium text-3xl leading-none" *ngIf="deviceSummary.smart?.power_on_hours; else unknownPoweredOn">{{ humanizeDuration(deviceSummary.smart?.power_on_hours * 60 * 60 * 1000, { round: true, largest: 1, units: ['y', 'd', 'h'] }) }}</div>
<div class="mt-2 font-medium text-3xl leading-none" *ngIf="deviceSummary.smart?.power_on_hours; else unknownPoweredOn">{{ deviceSummary.smart?.power_on_hours | deviceHours:config.powered_on_hours_unit:{ round: true, largest: 1, units: ['y', 'd', 'h'] } }}</div>
<ng-template #unknownPoweredOn><div class="mt-2 font-medium text-3xl leading-none">--</div></ng-template>
</div>
</div>
@@ -0,0 +1,3 @@
.text-disabled{
opacity: 0.8;
}
@@ -4,12 +4,13 @@ import {takeUntil} from 'rxjs/operators';
import {AppConfig} from 'app/core/config/app.config';
import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service';
import {Subject} from 'rxjs';
import humanizeDuration from 'humanize-duration'
import {MatDialog} from '@angular/material/dialog';
import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.component';
import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
import {DeviceStatusPipe} from 'app/shared/device-status.pipe';
import {DashboardDeviceArchiveDialogComponent} from '../dashboard-device-archive-dialog/dashboard-device-archive-dialog.component';
import {DashboardDeviceArchiveDialogService} from '../dashboard-device-archive-dialog/dashboard-device-archive-dialog.service';
@Component({
selector: 'app-dashboard-device',
@@ -20,6 +21,7 @@ export class DashboardDeviceComponent implements OnInit {
constructor(
private _configService: ScrutinyConfigService,
private _archiveService: DashboardDeviceArchiveDialogService,
public dialog: MatDialog,
) {
// Set the private defaults
@@ -27,15 +29,14 @@ export class DashboardDeviceComponent implements OnInit {
}
@Input() deviceSummary: DeviceSummaryModel;
@Input() deviceWWN: string;
@Output() deviceArchived = new EventEmitter<string>();
@Output() deviceUnarchived = new EventEmitter<string>();
@Output() deviceDeleted = new EventEmitter<string>();
config: AppConfig;
private _unsubscribeAll: Subject<void>;
readonly humanizeDuration = humanizeDuration;
deviceStatusForModelWithThreshold = DeviceStatusPipe.deviceStatusForModelWithThreshold
ngOnInit(): void {
@@ -72,11 +73,33 @@ export class DashboardDeviceComponent implements OnInit {
}
}
openArchiveDialog(): void {
if(this.deviceSummary.device.archived){
this._archiveService.unarchiveDevice(this.deviceSummary.device.wwn).subscribe((result) => {
if(result) {
this.deviceUnarchived.emit(this.deviceSummary.device.wwn)
}
})
return;
}
const dialogRef = this.dialog.open(DashboardDeviceArchiveDialogComponent, {
data: {
wwn: this.deviceSummary.device.wwn,
title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboard_display)
}
});
dialogRef.afterClosed().subscribe(result => {
if(result) {
this.deviceArchived.emit(this.deviceSummary.device.wwn);
}
})
}
openDeleteDialog(): void {
const dialogRef = this.dialog.open(DashboardDeviceDeleteDialogComponent, {
// width: '250px',
data: {
wwn: this.deviceWWN,
wwn: this.deviceSummary.device.wwn,
title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboard_display)
}
});
@@ -84,7 +107,7 @@ export class DashboardDeviceComponent implements OnInit {
dialogRef.afterClosed().subscribe(result => {
console.log('The dialog was closed', result);
if (result.success) {
this.deviceDeleted.emit(this.deviceWWN)
this.deviceDeleted.emit(this.deviceSummary.device.wwn)
}
});
}
@@ -7,6 +7,7 @@ import {DashboardDeviceComponent} from 'app/layout/common/dashboard-device/dashb
import {dashboardRoutes} from '../../../modules/dashboard/dashboard.routing';
import {MatMenuModule} from '@angular/material/menu';
import {DashboardDeviceDeleteDialogModule} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.module';
import {DashboardDeviceArchiveDialogModule} from '../dashboard-device-archive-dialog/dashboard-device-archive-dialog.module';
@NgModule({
declarations: [
@@ -19,7 +20,8 @@ import {DashboardDeviceDeleteDialogModule} from 'app/layout/common/dashboard-dev
MatIconModule,
MatMenuModule,
SharedModule,
DashboardDeviceDeleteDialogModule
DashboardDeviceDeleteDialogModule,
DashboardDeviceArchiveDialogModule
],
exports: [
DashboardDeviceComponent,
@@ -54,6 +54,14 @@
</div>
<div class="flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
<mat-label>Powered On Format</mat-label>
<mat-select [(ngModel)]="poweredOnHoursUnit">
<mat-option value="humanize">Humanize</mat-option>
<mat-option value="device_hours">Device Hours</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3">
<mat-label>Line stroke</mat-label>
<mat-select [(ngModel)]="lineStroke">
@@ -94,6 +102,23 @@
</mat-select>
</mat-form-field>
</div>
<div class="mt-6 mb-2">
<h3 class="text-lg font-medium">Quirks</h3>
<div class="w-full border-b mt-1"></div>
</div>
<div class="flex flex-col mt-5 gt-md:flex-row">
<mat-form-field class="flex-auto gt-xs:pr-3 gt-md:pr-3"
matTooltip="Discard historical temperature data retrieved from the SMART Command Transport (SCT) Data Table, which may be inaccurate for some drives. The current temperature is always stored."
matTooltipPosition="right">
<mat-label>Discard SCT Temperature History</mat-label>
<mat-select [(ngModel)]=discardSCTTempHistory>
<mat-option [value]=true>Enabled</mat-option>
<mat-option [value]=false>Disabled</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</mat-dialog-content>
@@ -7,7 +7,8 @@ import {
MetricsStatusThreshold,
TemperatureUnit,
LineStroke,
Theme
Theme,
DevicePoweredOnUnit
} from 'app/core/config/app.config';
import {ScrutinyConfigService} from 'app/core/config/scrutiny-config.service';
import {Subject} from 'rxjs';
@@ -24,8 +25,10 @@ export class DashboardSettingsComponent implements OnInit {
dashboardSort: string;
temperatureUnit: string;
fileSizeSIUnits: boolean;
poweredOnHoursUnit: string;
lineStroke: string;
theme: string;
discardSCTTempHistory: boolean;
statusThreshold: number;
statusFilterAttributes: number;
repeatNotifications: boolean;
@@ -51,9 +54,12 @@ export class DashboardSettingsComponent implements OnInit {
this.dashboardSort = config.dashboard_sort;
this.temperatureUnit = config.temperature_unit;
this.fileSizeSIUnits = config.file_size_si_units;
this.poweredOnHoursUnit = config.powered_on_hours_unit;
this.lineStroke = config.line_stroke;
this.theme = config.theme;
this.discardSCTTempHistory = config.collector.discard_sct_temp_history;
this.statusFilterAttributes = config.metrics.status_filter_attributes;
this.statusThreshold = config.metrics.status_threshold;
this.repeatNotifications = config.metrics.repeat_notifications;
@@ -68,8 +74,12 @@ export class DashboardSettingsComponent implements OnInit {
dashboard_sort: this.dashboardSort as DashboardSort,
temperature_unit: this.temperatureUnit as TemperatureUnit,
file_size_si_units: this.fileSizeSIUnits,
powered_on_hours_unit: this.poweredOnHoursUnit as DevicePoweredOnUnit,
line_stroke: this.lineStroke as LineStroke,
theme: this.theme as Theme,
collector: {
discard_sct_temp_history: this.discardSCTTempHistory
},
metrics: {
status_filter_attributes: this.statusFilterAttributes as MetricsStatusFilterAttributes,
status_threshold: this.statusThreshold as MetricsStatusThreshold,
@@ -1,4 +1,3 @@
<div *ngIf="summaryData; else emptyDashboard">
<div class="flex flex-col flex-auto w-full p-8 xs:p-2">
@@ -11,7 +10,15 @@
</div>
<!-- Action buttons -->
<div class="flex items-center">
<button matTooltip="not yet implemented" class="xs:hidden" mat-stroked-button>
<button class="xs:hidden" mat-stroked-button
[color]="showArchived ? 'primary' : null"
(click)="showArchived=!showArchived">
<mat-icon class="icon-size-20"
[color]="showArchived ? 'primary' : null"
[svgIcon]="'archive'"></mat-icon>
<span class="ml-2">Archived</span>
</button>
<button matTooltip="not yet implemented" class="ml-2 xs:hidden" mat-stroked-button>
<mat-icon class="icon-size-20"
[svgIcon]="'save'"></mat-icon>
<span class="ml-2">Export</span>
@@ -31,6 +38,12 @@
<mat-icon [svgIcon]="'more_vert'"></mat-icon>
</button>
<mat-menu #actionsMenu="matMenu">
<button mat-menu-item (click)="showArchived=!showArchived">
<mat-icon class="icon-size-20"
[color]="showArchived ? 'primary' : null"
[svgIcon]="'archive'"></mat-icon>
<span class="ml-2">Archived</span>
</button>
<button mat-menu-item
matTooltip="not yet implemented">
<mat-icon class="icon-size-20"
@@ -49,13 +62,16 @@
<div class="flex flex-wrap w-full" *ngFor="let hostId of hostGroups | keyvalue">
<h3 class="ml-4" *ngIf="hostId.key">{{hostId.key}}</h3>
<h3 class="ml-4" *ngIf="hostId.key">{{ hostId.key }}</h3>
<div class="flex flex-wrap w-full">
<app-dashboard-device (deviceDeleted)="onDeviceDeleted($event)"
class="flex gt-sm:w-1/2 min-w-80 p-4"
*ngFor="let deviceSummary of (deviceSummariesForHostGroup(hostId.value) | deviceSort:config.dashboard_sort:config.dashboard_display )"
[deviceWWN]="deviceSummary.device.wwn"
[deviceSummary]="deviceSummary"></app-dashboard-device>
<ng-container *ngFor="let deviceSummary of (deviceSummariesForHostGroup(hostId.value) | deviceSort:config.dashboard_sort:config.dashboard_display )">
<app-dashboard-device *ngIf="showArchived || !deviceSummary.device.archived"
(deviceArchived)="onDeviceArchived($event)"
(deviceUnarchived)="onDeviceUnarchived($event)"
(deviceDeleted)="onDeviceDeleted($event)"
class="flex gt-sm:w-1/2 min-w-80 p-4"
[deviceSummary]="deviceSummary"></app-dashboard-device>
</ng-container>
</div>
</div>
@@ -67,13 +83,13 @@
<div class="flex items-center justify-between">
<div class="flex flex-col">
<div class="font-bold text-md text-secondary uppercase tracking-wider mr-4">Temperature</div>
<div class="text-sm text-hint font-medium">Temperature history for each device </div>
<div class="text-sm text-hint font-medium">Temperature history for each device</div>
</div>
<div>
<button class="h-8 min-h-8 px-2"
mat-button
[matMenuTriggerFor]="tempRangeMenu">
<span class="font-medium text-sm text-hint">{{tempDurationKey}}</span>
<span class="font-medium text-sm text-hint">{{ tempDurationKey }}</span>
</button>
<mat-menu #tempRangeMenu="matMenu">
<button (click)="changeSummaryTempDuration('forever')" mat-menu-item>forever</button>
@@ -109,7 +125,8 @@
src="assets/images/dashboard-placeholder.png">
<h1>No Devices Detected!</h1>
<p style="max-width:700px;">Scrutiny includes a Collector agent that you must run on all of your systems. The Collector is responsible for detecting connected storage devices and collecting S.M.A.R.T data on a configurable schedule.</p>
<p style="max-width:700px;">Scrutiny includes a Collector agent that you must run on all of your systems. The Collector is responsible for detecting connected storage
devices and collecting S.M.A.R.T data on a configurable schedule.</p>
<p><br/>You can trigger the Collector manually by running the following command, then refreshing this page:</p>
<code>scrutiny-collector-metrics run</code>
@@ -34,6 +34,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
temperatureOptions: ApexOptions;
tempDurationKey = 'forever'
config: AppConfig;
showArchived: boolean;
// Private
private _unsubscribeAll: Subject<void>;
@@ -159,9 +160,18 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
for(const tempHistory of deviceSummary.temp_history){
const newDate = new Date(tempHistory.date);
let temperature;
switch (this.config.temperature_unit) {
case 'celsius':
temperature = tempHistory.temp;
break
case 'fahrenheit':
temperature = TemperaturePipe.celsiusToFahrenheit(tempHistory.temp)
break
}
deviceSeriesMetadata.data.push({
x: newDate,
y: TemperaturePipe.formatTemperature(tempHistory.temp, this.config.temperature_unit, false)
y: temperature
})
}
deviceTemperatureSeries.push(deviceSeriesMetadata)
@@ -206,6 +216,8 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
},
tooltip: {
theme: 'dark',
shared: true,
intersect: false,
x : {
format: 'MMM dd, yyyy HH:mm:ss'
},
@@ -248,6 +260,14 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
delete this.summaryData[wwn] // remove the device from the summary list.
}
onDeviceArchived(wwn: string): void {
this.summaryData[wwn].device.archived = true;
}
onDeviceUnarchived(wwn: string): void {
this.summaryData[wwn].device.archived = false;
}
/*
DURATION_KEY_WEEK = "week"
@@ -123,7 +123,7 @@
<div class="text-secondary text-md">Power Cycle Count</div>
</div>
<div *ngIf="smart_results[0]?.power_on_hours" class="my-2 col-span-2 lt-md:col-span-1">
<div matTooltip="{{humanizeDuration(smart_results[0]?.power_on_hours * 60 * 60 * 1000, { conjunction: ' and ', serialComma: false })}}">{{humanizeDuration(smart_results[0]?.power_on_hours *60 * 60 * 1000, { round: true, largest: 1, units: ['y', 'd', 'h'] })}}</div>
<div matTooltip="{{humanizeDuration(smart_results[0]?.power_on_hours * 60 * 60 * 1000, { conjunction: ' and ', serialComma: false })}}">{{ smart_results[0]?.power_on_hours | deviceHours:config.powered_on_hours_unit:{ round: true, largest: 1, units: ['y', 'd', 'h'] } }}</div>
<div class="text-secondary text-md">Powered On</div>
</div>
<div class="my-2 col-span-2 lt-md:col-span-1">
@@ -0,0 +1,52 @@
import { DeviceHoursPipe } from "./device-hours.pipe";
describe("DeviceHoursPipe", () => {
it("create an instance", () => {
const pipe = new DeviceHoursPipe();
expect(pipe).toBeTruthy();
});
describe("#transform", () => {
const testCases = [
{
input: 12345,
configuration: "device_hours",
result: "12345 hours",
},
{
input: 15273,
configuration: "humanize",
result: "1 year, 8 months, 3 weeks, 6 days, 15 hours",
},
{
input: 48,
configuration: null,
result: "2 days",
},
{
input: 168,
configuration: "scrutiny",
result: "1 week",
},
{
input: null,
configuration: "device_hours",
result: "Unknown",
},
{
input: null,
configuration: "humanize",
result: "Unknown",
},
];
testCases.forEach((test, index) => {
it(`format input '${test.input}' with configuration '${test.configuration}', should be '${test.result}' (testcase: ${index + 1})`, () => {
// test
const pipe = new DeviceHoursPipe();
const formatted = pipe.transform(test.input, test.configuration);
expect(formatted).toEqual(test.result);
});
});
});
});
@@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import humanizeDuration from 'humanize-duration';
@Pipe({ name: 'deviceHours' })
export class DeviceHoursPipe implements PipeTransform {
static format(hoursOfRunTime: number, unit: string, humanizeConfig: object): string {
if (hoursOfRunTime === null) {
return 'Unknown';
}
if (unit === 'device_hours') {
return `${hoursOfRunTime} hours`;
}
return humanizeDuration(hoursOfRunTime * 60 * 60 * 1000, humanizeConfig);
}
transform(hoursOfRunTime: number, unit = 'humanize', humanizeConfig: any = {}): string {
return DeviceHoursPipe.format(hoursOfRunTime, unit, humanizeConfig)
}
}
@@ -6,6 +6,7 @@ import { DeviceSortPipe } from './device-sort.pipe';
import { TemperaturePipe } from './temperature.pipe';
import { DeviceTitlePipe } from './device-title.pipe';
import { DeviceStatusPipe } from './device-status.pipe';
import { DeviceHoursPipe } from './device-hours.pipe';
@NgModule({
declarations: [
@@ -13,7 +14,8 @@ import { DeviceStatusPipe } from './device-status.pipe';
DeviceSortPipe,
TemperaturePipe,
DeviceTitlePipe,
DeviceStatusPipe
DeviceStatusPipe,
DeviceHoursPipe
],
imports: [
CommonModule,
@@ -28,7 +30,8 @@ import { DeviceStatusPipe } from './device-status.pipe';
DeviceSortPipe,
DeviceTitlePipe,
DeviceStatusPipe,
TemperaturePipe
TemperaturePipe,
DeviceHoursPipe
]
})
export class SharedModule
@@ -11,16 +11,16 @@ describe('TemperaturePipe', () => {
const testCases = [
{
'c': -273.15,
'f': -460,
'f': -459.66999999999996,
},{
'c': -34.44,
'f': -30,
'f': -29.991999999999997,
},{
'c': -23.33,
'f': -10,
'f': -9.993999999999993,
},{
'c': -17.78,
'f': -0,
'f': -0.0040000000000048885,
},{
'c': 0,
'f': 32,
@@ -29,10 +29,10 @@ describe('TemperaturePipe', () => {
'f': 50,
},{
'c': 26.67,
'f': 80,
'f': 80.006,
},{
'c': 37,
'f': 99,
'f': 98.6,
},{
'c': 60,
'f': 140,
@@ -42,8 +42,7 @@ describe('TemperaturePipe', () => {
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);
expect(numb).toEqual(test.f);
});
})
});
@@ -55,6 +54,11 @@ describe('TemperaturePipe', () => {
'unit': 'celsius',
'includeUnits': true,
'result': '26.67°C'
},{
'c': 26.6767,
'unit': 'celsius',
'includeUnits': true,
'result': '26.677°C'
},{
'c': 26.67,
'unit': 'celsius',
@@ -64,12 +68,17 @@ describe('TemperaturePipe', () => {
'c': 26.67,
'unit': 'fahrenheit',
'includeUnits': true,
'result': '80.006°F',
'result': '26.67°F',
},{
'c': 26.6767,
'unit': 'fahrenheit',
'includeUnits': true,
'result': '26.677°F',
},{
'c': 26.67,
'unit': 'fahrenheit',
'includeUnits': false,
'result': '80.006',
'result': '26.67',
}
]
testCases.forEach((test, index) => {
@@ -6,29 +6,35 @@ import {formatNumber} from '@angular/common';
})
export class TemperaturePipe implements PipeTransform {
static celsiusToFahrenheit(celsiusTemp: number): number {
return celsiusTemp * 9.0 / 5.0 + 32;
return celsiusTemp * 9/5 + 32;
}
static formatTemperature(celsiusTemp: number, unit: string, includeUnits: boolean): number|string {
let convertedTemp
let convertedUnitSuffix
static formatTemperature(temp: number, unit: string, includeUnits: boolean): number|string {
let unitSuffix
switch (unit) {
case 'celsius':
convertedTemp = celsiusTemp
convertedUnitSuffix = '°C'
unitSuffix = '°C'
break
case 'fahrenheit':
convertedTemp = TemperaturePipe.celsiusToFahrenheit(celsiusTemp)
convertedUnitSuffix = '°F'
unitSuffix = '°F'
break
}
if(includeUnits){
return formatNumber(convertedTemp, 'en-US') + convertedUnitSuffix
return formatNumber(temp, 'en-US') + unitSuffix
} else {
return formatNumber(convertedTemp, 'en-US',)
return formatNumber(temp, 'en-US',)
}
}
transform(celsiusTemp: number, unit = 'celsius', includeUnits = false): number|string {
let temperature;
switch (unit) {
case 'celsius':
temperature = celsiusTemp;
break
case 'fahrenheit':
temperature = TemperaturePipe.celsiusToFahrenheit(celsiusTemp)
break
}
return TemperaturePipe.formatTemperature(celsiusTemp, unit, includeUnits)
}