Add files via upload

This commit is contained in:
Manjari Srivastav 2024-01-24 12:22:35 +05:30 committed by GitHub
parent de08a7f970
commit 3ba65b93c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1770 additions and 0 deletions

65
drive-health/Dockerfile Normal file
View File

@ -0,0 +1,65 @@
# === Build Stage ===
FROM debian:bullseye-slim AS builder
ENV IS_DOCKER TRUE
LABEL org.opencontainers.image.source https://github.com/JustKato/drive-health
# Install build dependencies and runtime dependencies
RUN apt-get update && apt-get install -y \
gcc \
musl-dev \
libsqlite3-dev \
libsqlite3-0 \
wget \
&& rm -rf /var/lib/apt/lists/*
# Manually install Go 1.21
ENV GOLANG_VERSION 1.21.0
RUN wget https://golang.org/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz -O go.tgz \
&& tar -C /usr/local -xzf go.tgz \
&& rm go.tgz
ENV PATH /usr/local/go/bin:$PATH
# Set the environment variable for Go
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:$PATH
ENV GO111MODULE=on
# Create the directory and set it as the working directory
WORKDIR /app
# Copy the Go files and download dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy the source from the current directory to the Working Directory inside the container
COPY . .
# Build the Go app
RUN go build -o drive-health
# Cleanup build dependencies to reduce image size
RUN apt-get purge -y gcc musl-dev libsqlite3-dev wget \
&& apt-get autoremove -y \
&& apt-get clean
# === Final Stage ===
FROM debian:bullseye-slim AS final
# Set the environment variable
ENV IS_DOCKER TRUE
# Create the directory and set it as the working directory
WORKDIR /app
# Copy only the necessary files from the builder stage
COPY --from=builder /app/drive-health .
# Expose the necessary port
EXPOSE 8080
# Volume for external data
VOLUME [ "/data" ]
# Command to run the executable
CMD ["./drive-health"]

13
drive-health/LICENSE Normal file
View File

@ -0,0 +1,13 @@
Copyright [2024] [Daniel Legt]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

98
drive-health/README.md Normal file
View File

@ -0,0 +1,98 @@
## 📖 About
Drive Health is a program written in golang to help with tracking and monitoring of your hardware's temperature.
This tool had been conceived with the purpose of installing it on different servers I own with different configurations to help keep track of the temperature of hard-disks, ssds, nvme drives, etc...
### Features
- Disk Listing
- Temperature Graphing
- Disk activity logging
- [API](./lib/web/api.go)
![UI Example](./media/design_v1.webp)
## ❗ Disclaimer
I'm not exactly a linux hardware wizard, so I honestly have no clue about a lot of things and I myself can tell there's a lot to improve upon and that there's a lot of other things missing that are a little bit more obscure, I personally don't currently own any m.2 sata drives to test the code on, or many of the other drive types, I have only tested on HDD, SSD and NVMe drives, any issues opened would help me so much!
## ❗ Requirements
1. A linux machine, this will NOT work on macOS or on Windows, it's meant to be ran on servers as a service with which administrators can privately connect to for temperature logging.
2. Please make sure you have the [**drivetemp kernel drive**](https://docs.kernel.org/hwmon/drivetemp.html) you can check this by running `sudo modprobe drivetemp`.
The program depends on this to be able to log the temperature of your devices.
## 📖 How to use
1. Follow the `Deployment` section instrcutions to launch the program
2. Once the program has launched, access it in your browser
3. Enter the administrative username and password for the simple HTTP Auth
4. You now have access to the application, you can monitor your disk's temperature over a period of time.
## 🐦 Deployment
To deploy the application you have multiple choices, the preffered method should be one which runs the binary directly and not containerization, the `docker` image is taking up a wopping `1Gb+` because I have to include sqlite3-dev and musl-dev dependencies, which sucks, so I whole heartedly recommend just installing this on your system as a binary either with `SystemD` or whichever service manager you are using.
Download binaries from [the releases page](https://github.com/JustKato/drive-health/releases)
### 🐋 Docker
In the project there's a `docker-compose.prod.yml` which you can deploy on your server, you will notice that there's also a "dev" version, this version simply has a `build` instead of `image` property, so feel free to use either.
Please do take notice that I have just fed the `environment file` directly to the service via docker-compose, and I recommend you do the same but please feel free to pass in `environment` variables straight to the process as well.
[Docker Compose File](./docker-compose.prod.yml)
```yaml
version: "3.8"
services:
drive-health:
# Latest image pull, mention the specific version here please.
image: ghcr.io/justkato/drive-health:latest
# Restart in case of crashing
restart: unless-stopped
# Load environment variables from .env file
env_file:
- .env
# Mount the volume to the local drive
volumes:
- ./data:/data
# Setup application ports
ports:
- 5003:8080
```
### 💾 SystemD
When running with SystemD or any other service manager, please make sure you have a `.env` inside the `WorkingDirectory` of your runner, in the below example I will simply put my env in `/home/daniel/services/drive-health/.env`
```ini
[Unit]
Description=Drive Health Service
After=network.target
[Service]
Type=simple
User=daniel # Your user here
WorkingDirectory=/home/daniel/services/drive-health # The path to the service's directory
ExecStart=/home/daniel/services/drive-health/drive-health # The path to the binary
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
## ❔ FAQ
### How does it work?
Currently the program does not depend on any go library for hardware detection as I couldn't find anything that would not require root access while giving me the possibility to interrogate the temperature of the drives.
I chose not to depend on `lsblk` either, so how does the program work?
The program currently looks in `/sys/block` and then tries to make sense of the devices, I have had limited testing with my hardware specs, any issues being open in regards to different kinds of hardware would be highly appreciated
### Why not just run as root?
I really, REALLY, **REALLY** want to avoid asking people to run **ANY** program I write as root and even try and prevent that from happening since that's how things can go bad, especially because I am running actions over hardware devices.
## Support & Contribution
For support, bug reports, or feature requests, please open an issue on the [GitHub repository](https://github.com/JustKato/drive-health/issues). Contributions are welcome! Fork the repository, make your changes, and submit a pull request.
## License
This project is licensed under the [Apache License 2.0](./LICENSE).

50
drive-health/build.sh Normal file
View File

@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -o pipefail
set -u
# Function to display messages in color
echo_color() {
color=$1
text=$2
case $color in
"green") echo -e "\033[0;32m$text\033[0m" ;;
"yellow") echo -e "\033[0;33m$text\033[0m" ;;
"red") echo -e "\033[0;31m$text\033[0m" ;;
*) echo "$text" ;;
esac
}
# Getting GIT_VERSION from the most recent tag or commit hash
GIT_VERSION=$(git describe --tags --always)
if [ -z "$GIT_VERSION" ]; then
echo_color red "Error: Unable to determine GIT_VERSION."
exit 1
fi
APP_NAME="drive-health"
DIST_DIR="${DIST_DIR:-dist}"
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# make sure we are in the source dir
cd $SCRIPT_DIR;
# Create the dist directory if it doesn't exist
mkdir -p $DIST_DIR
# Build the application
echo_color yellow "[🦝] Building the application..."
GOOS=linux CGO_ENABLED=1 GOARCH=amd64 go build -o $DIST_DIR/$APP_NAME
# Copying additional resources...
cp -r static templates $DIST_DIR/
echo_color yellow "[🦝] Compilation and packaging completed, archiving..."
cd $DIST_DIR/
zip "drive-health_$GIT_VERSION.zip" -r .
# TODO: Add reliable method of cleaning up the compiled files optionally
cd $SCRIPT_DIR;

65
drive-health/deploy.sh Normal file
View File

@ -0,0 +1,65 @@
#!/bin/bash
# Function to display messages in color
echo_color() {
color=$1
text=$2
case $color in
"green") echo -e "\033[0;32m$text\033[0m" ;;
"yellow") echo -e "\033[0;33m$text\033[0m" ;;
"red") echo -e "\033[0;31m$text\033[0m" ;;
*) echo "$text" ;;
esac
}
# Getting GIT_VERSION from the most recent tag or commit hash
GIT_VERSION=$(git describe --tags --always)
if [ -z "$GIT_VERSION" ]; then
echo_color red "Error: Unable to determine GIT_VERSION."
exit 1
fi
# Run tests before proceeding
echo_color yellow "Running tests..."
if ! go test; then
echo_color red "Tests failed. Cancelling build process."
exit 1
fi
echo_color green "All tests passed successfully."
echo_color green "Starting the Docker build process with version $GIT_VERSION..."
LATEST_IMAGE_NAME="ghcr.io/justkato/drive-health:latest"
IMAGE_NAME="ghcr.io/justkato/drive-health:$GIT_VERSION"
echo_color yellow "Image to be built: $IMAGE_NAME"
# Confirmation to build
read -p "Are you sure you want to build an image? (y/N) " response
if [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then
# Building the Docker image
echo "Building Docker image: $IMAGE_NAME"
docker build --no-cache -t $IMAGE_NAME .
# Also tag this build as 'latest'
echo "Tagging image as latest: $LATEST_IMAGE_NAME"
docker tag $IMAGE_NAME $LATEST_IMAGE_NAME
else
echo_color red "Build cancelled."
exit 1
fi
# Prompt to push the image
read -p "Push image to repository? (y/N) " push_response
if [[ "$push_response" =~ ^([yY][eE][sS]|[yY])$ ]]; then
# Pushing the image
echo "Pushing image: $IMAGE_NAME"
docker push $IMAGE_NAME
# Pushing the 'latest' image
echo "Pushing latest image: $LATEST_IMAGE_NAME"
docker push $LATEST_IMAGE_NAME
else
echo_color red "Push cancelled."
fi
echo_color green "Ending the Docker build process..."

View File

@ -0,0 +1,14 @@
version: "3.8"
services:
drive-health:
# Build the current image
build: .
# Read straight from the .env file, or use the environment path
env_file:
- .env
volumes:
- ./dev_data:/data
# Setup application ports
ports:
- 8080:8080

View File

@ -0,0 +1,17 @@
version: "3.8"
services:
drive-health:
# Latest image pull, mention the specific version here please.
image: ghcr.io/justkato/drive-health:latest
# Restart in case of crashing
restart: unless-stopped
# Load environment variables from .env file
env_file:
- .env
# Mount the volume to the local drive
volumes:
- ./data:/data
# Setup application ports
ports:
- 5003:8080

45
drive-health/go.mod Normal file
View File

@ -0,0 +1,45 @@
module github.com/JustKato/drive-health
go 1.21.6
require (
github.com/gin-gonic/gin v1.9.1
github.com/joho/godotenv v1.5.1
github.com/wcharczuk/go-chart/v2 v2.1.1
gorm.io/driver/sqlite v1.5.4
gorm.io/gorm v1.25.5
)
require (
github.com/blend/go-sdk v1.20220411.3 // indirect
github.com/bytedance/sonic v1.10.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.17.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.19 // 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/v2 v2.1.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/image v0.11.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

141
drive-health/go.sum Normal file
View File

@ -0,0 +1,141 @@
github.com/blend/go-sdk v1.20220411.3 h1:GFV4/FQX5UzXLPwWV03gP811pj7B8J2sbuq+GJQofXc=
github.com/blend/go-sdk v1.20220411.3/go.mod h1:7lnH8fTi6U4i1fArEXRyOIY2E1X4MALg09qsQqY1+ak=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74=
github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wcharczuk/go-chart/v2 v2.1.1 h1:2u7na789qiD5WzccZsFz4MJWOJP72G+2kUuJoSNqWnE=
github.com/wcharczuk/go-chart/v2 v2.1.1/go.mod h1:CyCAUt2oqvfhCl6Q5ZvAZwItgpQKZOkCJGb+VGv6l14=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo=
golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -0,0 +1,95 @@
package config
import (
"os"
"strconv"
)
type DHConfig struct {
CleanupServiceFrequency int `json:"cleanupServiceFrequency"`
DiskFetchFrequency int `json:"diskFetchFrequency"`
MaxHistoryAge int `json:"maxHistoryAge"`
DatabaseFilePath string `json:"databaseFilePath"`
Listen string `json:"listen"`
IdentityUsername string `json:"identityUsername"`
IdentityPassword string `json:"identityPassword"`
IsDocker bool `json:isDocker`
DebugMode bool `json:"debugMode"`
}
var config *DHConfig = nil
func GetConfiguration() *DHConfig {
if config != nil {
return config
}
config = &DHConfig{
DiskFetchFrequency: 5,
CleanupServiceFrequency: 3600,
MaxHistoryAge: 2592000,
DatabaseFilePath: "./data.sqlite",
IdentityUsername: "admin",
IdentityPassword: "admin",
IsDocker: false,
Listen: ":8080",
}
if val, exists := os.LookupEnv("DISK_FETCH_FREQUENCY"); exists {
if intValue, err := strconv.Atoi(val); err == nil {
config.DiskFetchFrequency = intValue
}
}
if val, exists := os.LookupEnv("CLEANUP_SERVICE_FREQUENCY"); exists {
if intValue, err := strconv.Atoi(val); err == nil {
config.CleanupServiceFrequency = intValue
}
}
if val, exists := os.LookupEnv("MAX_HISTORY_AGE"); exists {
if intValue, err := strconv.Atoi(val); err == nil {
config.MaxHistoryAge = intValue
}
}
if val, exists := os.LookupEnv("LISTEN"); exists {
config.Listen = val
}
if val, exists := os.LookupEnv("DATABASE_FILE_PATH"); exists {
config.DatabaseFilePath = val
}
if val, exists := os.LookupEnv("IDENTITY_USERNAME"); exists {
config.IdentityUsername = val
}
if val, exists := os.LookupEnv("IDENTITY_PASSWORD"); exists {
config.IdentityPassword = val
}
if val, exists := os.LookupEnv("DEBUG_MODE"); exists {
if isDebug, err := strconv.ParseBool(val); err == nil {
config.DebugMode = isDebug
}
}
if val, exists := os.LookupEnv("IS_DOCKER"); exists {
if isDocker, err := strconv.ParseBool(val); err == nil {
config.IsDocker = isDocker
config.DatabaseFilePath = "/data/data.sqlite"
}
}
return config
}

View File

@ -0,0 +1,198 @@
package hardware
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/JustKato/drive-health/lib/config"
"gorm.io/gorm"
)
func GetSystemHardDrives(db *gorm.DB, olderThan *time.Time, newerThan *time.Time) ([]*HardDrive, error) {
var systemHardDrives []*HardDrive
// List all block devices
devices, err := os.ReadDir("/sys/block/")
if err != nil {
return nil, fmt.Errorf("failed to list block devices: %w", err)
}
for _, device := range devices {
deviceName := device.Name()
// Skip non-physical devices (like loop and ram devices)
// TODO: Read more about this, there might be some other devices we should or should not skip
if strings.HasPrefix(deviceName, "loop") || strings.HasPrefix(deviceName, "ram") {
continue
}
// Read device details
model, _ := os.ReadFile(fmt.Sprintf("/sys/block/%s/device/model", deviceName))
serial, _ := os.ReadFile(fmt.Sprintf("/sys/block/%s/device/serial", deviceName))
sizeBytes, _ := os.ReadFile(fmt.Sprintf("/sys/block/%s/size", deviceName))
size := convertSizeToString(sizeBytes)
transport := getTransportType(deviceName)
// TODO: Maybe find a better way?
if size == "0 Bytes" {
// This looks like an invalid device, skip it.
if config.GetConfiguration().DebugMode {
fmt.Printf("[🟨] Igoring device:[/dev/%s], reported size of 0\n", deviceName)
}
continue
}
hwid, err := getHardwareID(deviceName)
if err != nil {
if config.GetConfiguration().DebugMode {
fmt.Printf("[🟨] No unique identifier found for device:[/dev/%s] unique identifier\n", deviceName)
}
continue
}
hd := &HardDrive{
Name: deviceName,
Transport: transport,
Model: strings.TrimSpace(string(model)),
Serial: strings.TrimSpace(string(serial)),
Size: size,
Type: getDriveType(deviceName),
HWID: hwid,
}
systemHardDrives = append(systemHardDrives, hd)
}
var updatedHardDrives []*HardDrive
for _, sysHDD := range systemHardDrives {
var existingHD HardDrive
q := db.Where("hw_id = ?", sysHDD.HWID)
if newerThan != nil && olderThan != nil {
q = q.Preload("Temperatures", "time_stamp < ? AND time_stamp > ?", newerThan, olderThan)
}
result := q.First(&existingHD)
if result.Error == gorm.ErrRecordNotFound {
// Hard drive not found, create new
db.Create(&sysHDD)
updatedHardDrives = append(updatedHardDrives, sysHDD)
} else {
// Hard drive found, update existing
existingHD.Name = sysHDD.Name
existingHD.Transport = sysHDD.Transport
existingHD.Size = sysHDD.Size
existingHD.Model = sysHDD.Model
existingHD.Type = sysHDD.Type
db.Save(&existingHD)
updatedHardDrives = append(updatedHardDrives, &existingHD)
}
}
return updatedHardDrives, nil
}
func getTransportType(deviceName string) string {
transportLink, err := filepath.EvalSymlinks(fmt.Sprintf("/sys/block/%s/device", deviceName))
if err != nil {
return "Unknown"
}
if strings.Contains(transportLink, "/usb/") {
return "USB"
} else if strings.Contains(transportLink, "/ata") {
return "SATA"
} else if strings.Contains(transportLink, "/nvme/") {
return "NVMe"
}
return "Other"
}
func convertSizeToString(sizeBytes []byte) string {
// Convert the size from a byte slice to a string, then to an integer
sizeStr := strings.TrimSpace(string(sizeBytes))
sizeSectors, err := strconv.ParseInt(sizeStr, 10, 64)
if err != nil {
return "Unknown"
}
// Convert from 512-byte sectors to bytes
sizeInBytes := sizeSectors * 512
// Define size units
const (
_ = iota // ignore first value by assigning to blank identifier
KB float64 = 1 << (10 * iota)
MB
GB
TB
)
var size float64 = float64(sizeInBytes)
var unit string
// Determine the unit to use
switch {
case size >= TB:
size /= TB
unit = "TB"
case size >= GB:
size /= GB
unit = "GB"
case size >= MB:
size /= MB
unit = "MB"
case size >= KB:
size /= KB
unit = "KB"
default:
unit = "Bytes"
}
// Return the formatted size
return fmt.Sprintf("%.2f %s", size, unit)
}
// Look throug /sys/block/device/ and try and find the unique identifier of the device.
func getHardwareID(deviceName string) (string, error) {
// Define potential ID file paths
idFilePaths := []string{
"/sys/block/" + deviceName + "/device/wwid",
"/sys/block/" + deviceName + "/device/wwn",
"/sys/block/" + deviceName + "/device/serial",
}
// Try to read each file and return the first successful read
for _, path := range idFilePaths {
if idBytes, err := os.ReadFile(path); err == nil {
return strings.TrimSpace(string(idBytes)), nil
}
}
// Return an empty string if no ID is found
return "", fmt.Errorf("could not find unique identifier for %s", deviceName)
}
// Figure out what kind of device this is by reading if it's rotational or not
func getDriveType(deviceName string) string {
// Check if the drive is rotational (HDD)
if isRotational, _ := os.ReadFile(fmt.Sprintf("/sys/block/%s/queue/rotational", deviceName)); string(isRotational) == "1\n" {
return "HDD"
}
// Check if the drive is NVMe
if strings.HasPrefix(deviceName, "nvme") {
return "NVMe"
}
// Default to SSD for non-rotational and non-NVMe drives
return "SSD"
}

View File

@ -0,0 +1,94 @@
package hardware
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
type HardDrive struct {
ID uint `gorm:"primarykey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
Transport string
Size string
Model string
Serial string
Type string
HWID string
Temperatures []HardDriveTemperature `gorm:"foreignKey:HardDriveID"`
}
type HardDriveTemperature struct {
gorm.Model
HardDriveID uint
TimeStamp time.Time
Temperature int
}
// A snapshot in time of the current state of the harddrives
type HardwareSnapshot struct {
TimeStamp time.Time
HDD []*HardDrive
}
type Snapshots struct {
List []*HardwareSnapshot
}
func (h *HardDrive) GetTemperature() int {
possiblePaths := []string{
"/sys/block/" + h.Name + "/device/hwmon/",
"/sys/block/" + h.Name + "/device/",
"/sys/block/" + h.Name + "/device/generic/device/",
}
for _, path := range possiblePaths {
// Try HDD/SSD path
temp, found := h.getTemperatureFromPath(path)
if found {
return temp
}
}
fmt.Printf("[🛑] Failed to get temperature for %s\n", h.Name)
return -1
}
func (h *HardDrive) getTemperatureFromPath(basePath string) (int, bool) {
hwmonDirs, err := os.ReadDir(basePath)
if err != nil {
return 0, false
}
for _, dir := range hwmonDirs {
if strings.HasPrefix(dir.Name(), "hwmon") {
tempPath := filepath.Join(basePath, dir.Name(), "temp1_input")
if _, err := os.Stat(tempPath); err == nil {
tempBytes, err := os.ReadFile(tempPath)
if err != nil {
continue
}
tempStr := strings.TrimSpace(string(tempBytes))
temperature, err := strconv.Atoi(tempStr)
if err != nil {
continue
}
// Convert millidegree Celsius to degree Celsius
return temperature / 1000, true
}
}
}
return 0, false
}

View File

@ -0,0 +1,186 @@
package svc
import (
"bytes"
"fmt"
"time"
"github.com/JustKato/drive-health/lib/config"
"github.com/JustKato/drive-health/lib/hardware"
"github.com/wcharczuk/go-chart/v2"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var db *gorm.DB
// Initialize the database connection
func InitDB() {
var err error
dbPath := config.GetConfiguration().DatabaseFilePath
db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
// This should basically never happen, unless the path to the database
// is inaccessible, doesn't exist or there's no permission to it, which
// should and will crash the program
panic("failed to connect database")
}
// Migrate the schema
db.AutoMigrate(&hardware.HardDrive{}, &hardware.HardDriveTemperature{})
}
// Fetch the open database pointer
func GetDatabaseRef() *gorm.DB {
return db
}
// Log the temperature of the disks
func LogDriveTemps() error {
drives, err := hardware.GetSystemHardDrives(db, nil, nil)
if err != nil {
return err
}
for _, hdd := range drives {
temp := hdd.GetTemperature()
db.Create(&hardware.HardDriveTemperature{
HardDriveID: hdd.ID,
TimeStamp: time.Now(),
Temperature: temp,
})
}
return nil
}
// Run the logging service, this will periodically log the temperature of the disks with the LogDriveTemps function
func RunLoggerService() {
fmt.Println("[🦝] Initializing Temperature Logging Service...")
tickTime := time.Duration(config.GetConfiguration().DiskFetchFrequency) * time.Second
// Snapshot taking routine
go func() {
for {
time.Sleep(tickTime)
err := LogDriveTemps()
if err != nil {
fmt.Printf("[🛑] Temperature logging failed: %s\n", err)
}
}
}()
}
// Generate a PNG based upon a HDD id and a date range
func GetDiskGraphImage(hddID int, newerThan *time.Time, olderThan *time.Time) (*bytes.Buffer, error) {
var hdd hardware.HardDrive
// Fetch by a combination of fields
q := db.Where("id = ?", hddID)
if newerThan == nil || olderThan == nil {
q = q.Preload("Temperatures")
} else {
q = q.Preload("Temperatures", "time_stamp < ? AND time_stamp > ?", newerThan, olderThan)
}
// Query for the instance
result := q.First(&hdd)
if result.Error != nil {
return nil, result.Error
}
// Prepare slices for X (time) and Y (temperature) values
var xValues []time.Time
var yValues []float64
for _, temp := range hdd.Temperatures {
xValues = append(xValues, temp.TimeStamp)
yValues = append(yValues, float64(temp.Temperature))
}
// Allocate a buffer for the graph image
graphImageBuffer := bytes.NewBuffer([]byte{})
// TODO: Graph dark theme
// Generate the chart
graph := chart.Chart{
Title: fmt.Sprintf("%s:%s[%s]", hdd.Name, hdd.Serial, hdd.Size),
TitleStyle: chart.Style{
FontSize: 14,
},
// TODO: Implement customizable sizing
Width: 1280,
Background: chart.Style{
Padding: chart.Box{
Top: 20, Right: 20, Bottom: 20, Left: 20,
},
},
XAxis: chart.XAxis{
Name: "Time",
ValueFormatter: func(v interface{}) string {
if ts, isValidTime := v.(float64); isValidTime {
t := time.Unix(int64(ts/1e9), 0)
return t.Format("Jan 2 2006, 15:04")
}
return ""
},
Style: chart.Style{},
GridMajorStyle: chart.Style{
StrokeColor: chart.ColorAlternateGray,
StrokeWidth: 0.5,
},
GridMinorStyle: chart.Style{
StrokeColor: chart.ColorAlternateGray.WithAlpha(64),
StrokeWidth: 0.25,
},
},
YAxis: chart.YAxis{
Name: "Temperature (C)",
Style: chart.Style{},
GridMajorStyle: chart.Style{
StrokeColor: chart.ColorAlternateGray,
StrokeWidth: 0.5,
},
GridMinorStyle: chart.Style{
StrokeColor: chart.ColorAlternateGray.WithAlpha(64),
StrokeWidth: 0.25,
},
},
Series: []chart.Series{
chart.TimeSeries{
Name: "Temperature",
XValues: xValues,
YValues: yValues,
Style: chart.Style{
StrokeColor: chart.ColorCyan,
StrokeWidth: 2.0,
},
},
},
}
// Add a legend to the chart
graph.Elements = []chart.Renderable{
chart.Legend(&graph, chart.Style{
Padding: chart.Box{
Top: 5, Right: 5, Bottom: 5, Left: 5,
},
FontSize: 10,
}),
}
// Render the chart into the byte buffer
err := graph.Render(chart.PNG, graphImageBuffer)
if err != nil {
return nil, err
}
return graphImageBuffer, nil
}

View File

@ -0,0 +1,45 @@
package svc
import (
"fmt"
"time"
"github.com/JustKato/drive-health/lib/config"
"github.com/JustKato/drive-health/lib/hardware"
)
// Delete all thermal entries that are older than X amount of seconds
func CleanupOldData() error {
cfg := config.GetConfiguration()
beforeDate := time.Now().Add(-1 * time.Duration(cfg.MaxHistoryAge) * time.Second)
deleteResult := db.Where("time_stamp < ?", beforeDate).Delete(&hardware.HardDriveTemperature{})
if deleteResult.Error != nil {
fmt.Printf("[🛑] Error during cleanup: %s\n", deleteResult.Error)
return db.Error
}
if deleteResult.RowsAffected > 0 {
fmt.Printf("[🛑] Cleaned up %v entries before %s\n", deleteResult.RowsAffected, beforeDate)
}
return nil
}
func RunCleanupService() {
fmt.Println("[🦝] Initializing Log Cleanup Service...")
tickTime := time.Duration(config.GetConfiguration().CleanupServiceFrequency) * time.Second
// Snapshot taking routine
go func() {
for {
time.Sleep(tickTime)
err := CleanupOldData()
if err != nil {
fmt.Printf("🛑 Cleanup process failed: %s\n", err)
}
}
}()
}

View File

@ -0,0 +1,95 @@
package web
import (
"net/http"
"strconv"
"time"
"github.com/JustKato/drive-health/lib/hardware"
"github.com/JustKato/drive-health/lib/svc"
"github.com/gin-gonic/gin"
)
func setupApi(r *gin.Engine) {
api := r.Group("/api/v1")
// Fetch the chart image for the disk's temperature
api.GET("/disks/:diskid/chart", func(ctx *gin.Context) {
diskIDString := ctx.Param("diskid")
diskId, err := strconv.Atoi(diskIDString)
if err != nil {
ctx.AbortWithStatusJSON(400, gin.H{
"error": err.Error(),
"message": "Invalid Disk ID",
})
return
}
var olderThan, newerThan *time.Time
if ot := ctx.Query("older"); ot != "" {
if otInt, err := strconv.ParseInt(ot, 10, 64); err == nil {
otTime := time.UnixMilli(otInt)
olderThan = &otTime
}
}
if nt := ctx.Query("newer"); nt != "" {
if ntInt, err := strconv.ParseInt(nt, 10, 64); err == nil {
ntTime := time.UnixMilli(ntInt)
newerThan = &ntTime
}
}
graphData, err := svc.GetDiskGraphImage(diskId, newerThan, olderThan)
if err != nil {
ctx.AbortWithStatusJSON(500, gin.H{
"error": err.Error(),
"message": "Graph generation issue",
})
return
}
// Set the content type header
ctx.Writer.Header().Set("Content-Type", "image/png")
// Write the image data to the response
ctx.Writer.WriteHeader(http.StatusOK)
_, err = graphData.WriteTo(ctx.Writer)
if err != nil {
ctx.AbortWithStatusJSON(500, gin.H{
"error": err.Error(),
"message": "Write error",
})
return
}
})
// Get a list of all the disks
api.GET("/disks", func(ctx *gin.Context) {
olderThan := time.Now().Add(time.Minute * time.Duration(10) * -1)
newerThan := time.Now()
// Fetch the disk list
disks, err := hardware.GetSystemHardDrives(svc.GetDatabaseRef(), &olderThan, &newerThan)
if err != nil {
ctx.Error(err)
}
if ctx.Request.URL.Query().Get("temp") != "" {
for _, d := range disks {
d.GetTemperature()
}
}
ctx.JSON(http.StatusOK, gin.H{
"message": "Disk List",
"disks": disks,
})
})
}

View File

@ -0,0 +1,11 @@
package web
import "github.com/gin-gonic/gin"
func BasicAuthMiddleware(username, password string) gin.HandlerFunc {
authorized := gin.Accounts{
username: password,
}
return gin.BasicAuth(authorized)
}

View File

@ -0,0 +1,66 @@
package web
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/JustKato/drive-health/lib/hardware"
"github.com/JustKato/drive-health/lib/svc"
"github.com/gin-gonic/gin"
)
func setupFrontend(r *gin.Engine) {
r.LoadHTMLGlob("templates/*")
r.Static("/static", "./static")
// Set up a route for the root URL
r.GET("/", func(ctx *gin.Context) {
hardDrives, err := hardware.GetSystemHardDrives(svc.GetDatabaseRef(), nil, nil)
if err != nil {
ctx.AbortWithStatus(500)
}
for _, hdd := range hardDrives {
hdd.GetTemperature()
}
var olderThan, newerThan *time.Time
if ot := ctx.Query("older"); ot != "" {
fmt.Printf("ot = %s\n", ot)
if otInt, err := strconv.ParseInt(ot, 10, 64); err == nil {
otTime := time.UnixMilli(otInt)
olderThan = &otTime
}
}
if nt := ctx.Query("newer"); nt != "" {
fmt.Printf("nt = %s\n", nt)
if ntInt, err := strconv.ParseInt(nt, 10, 64); err == nil {
ntTime := time.UnixMilli(ntInt)
newerThan = &ntTime
}
}
if olderThan == nil {
genTime := time.Now().Add(time.Hour * -1)
olderThan = &genTime
}
if newerThan == nil {
genTime := time.Now()
newerThan = &genTime
}
// Render the HTML template
ctx.HTML(http.StatusOK, "index.html", gin.H{
"drives": hardDrives,
"older": olderThan.UnixMilli(),
"newer": newerThan.UnixMilli(),
})
})
}

View File

@ -0,0 +1,16 @@
package web
import (
"net/http"
"github.com/gin-gonic/gin"
)
func setupHealth(r *gin.Engine) {
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "Pong",
})
})
}

View File

@ -0,0 +1,29 @@
package web
import (
"github.com/JustKato/drive-health/lib/config"
"github.com/gin-gonic/gin"
)
func SetupRouter() *gin.Engine {
cfg := config.GetConfiguration()
if !cfg.DebugMode {
// Set gin to release
gin.SetMode(gin.ReleaseMode)
}
// Initialize the Gin engine
r := gin.Default()
r.Use(BasicAuthMiddleware(cfg.IdentityUsername, cfg.IdentityPassword))
// Setup Health Pings
setupHealth(r)
// Setup Api
setupApi(r)
// Setup Frontend
setupFrontend(r)
return r
}

63
drive-health/main.go Normal file
View File

@ -0,0 +1,63 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/JustKato/drive-health/lib/config"
"github.com/JustKato/drive-health/lib/svc"
"github.com/JustKato/drive-health/lib/web"
"github.com/joho/godotenv"
)
func main() {
// Load .env file if it exists
if err := godotenv.Load(); err != nil {
log.Println("[🟨] No .env file found")
}
// Init the database
svc.InitDB()
cfg := config.GetConfiguration()
router := web.SetupRouter()
srv := &http.Server{
Addr: cfg.Listen,
Handler: router,
}
// Run the server in a goroutine
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[🛑] listening failed: %s\n", err)
}
}()
// Run the hardware service
svc.RunLoggerService()
// Run the cleanup service
svc.RunCleanupService()
// Setting up signal capturing
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Block until a signal is received
<-quit
log.Println("[🦝] Shutting down server...")
// Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("[🛑] Server forced to shutdown:", err)
}
log.Println("[🦝] Server exiting")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -0,0 +1,55 @@
/**
* @typedef {number}
*/
var initialInputOlder = 0; // miliseconds Unix TimeStamp
/**
* @typedef {number}
*/
var initialInputNewer = 0; // miliseconds Unix TimeStamp
/**
* @typedef {HTMLInputElement}
*/
var olderThanInputElement;
/**
* @typedef {HTMLInputElement}
*/
var newerThanInputElement;
document.addEventListener(`DOMContentLoaded`, initializePage)
function initializePage() {
// Update the page's time filter
initialInputOlder = Number(document.getElementById(`inp-older`).textContent.trim())
initialInputNewer = Number(document.getElementById(`inp-newer`).textContent.trim())
// Bind the date elements
olderThanInputElement = document.getElementById(`olderThan`);
newerThanInputElement = document.getElementById(`newerThan`);
olderThanInputElement.value = convertTimestampToDateTimeLocal(initialInputOlder);
newerThanInputElement.value = convertTimestampToDateTimeLocal(initialInputNewer);
}
// Handle one of the date elements having their value changed.
function applyDateInterval() {
const olderTimeStamp = new Date(olderThanInputElement.value).getTime()
const newerTimeStamp = new Date(newerThanInputElement.value).getTime()
window.location.href = `/?older=${olderTimeStamp}&newer=${newerTimeStamp}`;
}
/**
* Converts a Unix timestamp to a standard datetime string
* @param {number} timestamp - The Unix timestamp in milliseconds.
* @returns {string} - A normal string with Y-m-d H:i:s format
*/
function convertTimestampToDateTimeLocal(timestamp) {
const date = new Date(timestamp);
const offset = date.getTimezoneOffset() * 60000; // offset in milliseconds
const localDate = new Date(date.getTime() - offset);
return localDate.toISOString().slice(0, 19);
}

View File

@ -0,0 +1,188 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&family=Roboto:wght@100;300;400&display=swap');
:root {
--bg0: #202327;
--bg1: #282d33;
--bg2: #31373f;
--bg3: #3e4248;
--bg4: #1a1c1f;
--fg0: #bbc0ca;
--fg1: #434c56;
--acc1: #2aa3f4;
--acc1BG0: #2aa3f450;
--acc1BG1: #2aa3f430;
--acc2: #2af488;
--acc2BG0: #2af48850;
--acc2BG1: #2af48830;
--acc3: #f4e02a;
--acc3BG0: #f4e02a50;
--acc3BG1: #f4e02a30;
}
:root {
color: var(--fg0);
font-family: "Noto Sans Mono", "Roboto", sans-serif;
}
html, body {
margin: 0;
padding: 0;
width: 100vw;
overflow: auto;
background-color: var(--bg0);
}
.container {
margin: 1rem auto;
max-width: 768px;
border-radius: 8px;
border: 1px solid var(--fg1);
background-color: var(--bg1);
overflow: hidden;
}
.container-titlebar {
width: 100%;
background-color: var(--bg2);
}
.container .pad {
padding: .5rem 1rem;
}
.container-titlebar h4 {
padding: 0;
margin: 0;
}
.badge {
font-size: 12px;
font-weight: bold;
padding: .1rem .4rem;
display: inline-block;
border-radius: 3px;
color: var(--acc1);
background-color: var(--acc1BG0);
border: 1px solid var(--acc1BG1);
}
.badge[type="HDD"] {
color: var(--acc2);
background-color: var(--acc2BG0);
border: 1px solid var(--acc2BG1);
}
.badge[type="NVMe"] {
color: var(--acc3);
background-color: var(--acc3BG0);
border: 1px solid var(--acc3BG1);
}
.grooved {
background-color: var(--bg0);
border: 1px solid var(--bg1);
font-size: 12px;
padding: .1rem .3rem;
border-radius: 4px;
}
/* Table */
table {
width: 100%;
border-collapse: collapse;
}
table thead tr {
border-bottom: 1px solid var(--bg2);
}
.graph-image {
max-width: 100%;
}
.disk-graph-entry {
background-color: var(--bg3);
border-radius: 8px;
padding: .3rem .5rem;
}
/* Controls */
input {
padding: .25rem .5rem;
font-size: 16px;
background-color: var(--bg3);
color: var(--fg0);
border: 1px solid var(--fg1);
border-radius: 4px;
}
.btn {
font-size: 16px;
padding: .5rem 1rem;
border: 1px solid var(--fg1);
border-radius: 6px;
background-color: var(--bg4);
color: var(--fg0);
cursor: pointer;
}
.btn:hover {
background-color: var(--bg3);
}
.input-grp {
display: flex;
flex-flow: column;
}
.input-grp label {
margin-bottom: .25rem;
font-weight: bold;
}
.graph-controls {
display: flex;
flex-flow: wrap;
width: 100%;
}
.graph-controls input:nth-child(0) {
margin-right: 1rem;
}
.controls-panel {
display: flex;
flex-flow: column;
padding: 1rem 0;
border-bottom: 1px solid var(--fg1);
margin-bottom: 1rem;
}

View File

@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/style.css">
<title>Drive Health Dashboard</title>
</head>
{{ $older := .older }}
{{ $newer := .newer }}
<body>
<div class="container bordered">
<div class="container-titlebar">
<div class="pad">
<h4>Available Disks</h4>
</div>
</div>
<div class="container-body">
<div class="pad">
{{ if len .drives }}
<table id="disks-table">
<thead>
<tr>
<td>ID</td>
<td>Name</td>
<td>Model</td>
<td>Serial</td>
<td>Temperature</td>
</tr>
</thead>
<tbody id="disk-table-body">
{{ range .drives }}
{{ $temp := .GetTemperature }}
<tr>
<td>#{{ .ID }}</td>
<td> {{ .Name }}</td>
<td> {{ .Model }}</td>
<td> {{ .Serial }}</td>
{{ if gt $temp 50 }} <!-- Temperature greater than 50°C -->
<td style="color: red;">{{ $temp }}&deg;C</td>
{{ else if gt $temp 30 }} <!-- Temperature between 31°C and 50°C -->
<td style="color: orange;">{{ $temp }}&deg;C</td>
{{ else }} <!-- Temperature 30°C or below -->
<td style="color: lime;">{{ $temp }}&deg;C</td>
{{ end }}
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p>No hard drives found.</p>
{{ end }}
</div>
</div>
</div>
<div class="container bordered">
<div class="container-titlebar">
<div class="pad">
<h4>Temperature Graph</h4>
</div>
</div>
<div class="container-body">
<div class="pad">
<!-- Controls -->
<div class="controls-panel">
<div class="graph-controls">
<span id="inp-older" style="display: none !important" hidden="true">{{ .older }}</span>
<span id="inp-newer" style="display: none !important" hidden="true">{{ .newer }}</span>
<div class="input-grp" style="margin-right: 1rem;">
<label for="olderThan">From Date</label>
<input id="olderThan" type="datetime-local" class="date-change-inp">
</div>
<div class="input-grp">
<label for="newerThan">To Date</label>
<input id="newerThan" type="datetime-local" class="date-change-inp">
</div>
</div>
<div class="btn-group">
<button type="button" class="btn" onclick="applyDateInterval()" style="margin-top: 1rem;">
Filter
</button>
</div>
</div>
<!-- Drives -->
{{ if len .drives }}
{{ range .drives }}
<div class="disk-graph-entry bordered" id="disk-temp-{{ .ID }}" style="position: relative;">
<div class="badge" type="{{.Type}}" style="position: absolute; top: 1rem; right: 1rem;">/dev/{{.Name}}</div>
<h4>{{.Model}}:{{.HWID}} <span class="grooved">{{.Size}}</span></h4>
<a href="/api/v1/disks/{{.ID}}/chart" target="_blank">
<img class="graph-image" src="/api/v1/disks/{{.ID}}/chart?older={{ $older }}&newer={{ $newer }}" alt="{{ .Model }} Image">
</a>
</div>
<br>
{{ end }}
{{ else }}
<p>No hard drives found.</p>
{{ end }}
</div>
</div>
</div>
<script src="/static/main.js"></script>
</body>
</html>