diff --git a/drive-health/Dockerfile b/drive-health/Dockerfile new file mode 100644 index 0000000..13a6f44 --- /dev/null +++ b/drive-health/Dockerfile @@ -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"] diff --git a/drive-health/LICENSE b/drive-health/LICENSE new file mode 100644 index 0000000..f738181 --- /dev/null +++ b/drive-health/LICENSE @@ -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. \ No newline at end of file diff --git a/drive-health/README.md b/drive-health/README.md new file mode 100644 index 0000000..3cdc5ad --- /dev/null +++ b/drive-health/README.md @@ -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). \ No newline at end of file diff --git a/drive-health/build.sh b/drive-health/build.sh new file mode 100644 index 0000000..0906293 --- /dev/null +++ b/drive-health/build.sh @@ -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; \ No newline at end of file diff --git a/drive-health/deploy.sh b/drive-health/deploy.sh new file mode 100644 index 0000000..51a0b85 --- /dev/null +++ b/drive-health/deploy.sh @@ -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..." \ No newline at end of file diff --git a/drive-health/docker-compose.dev.yml b/drive-health/docker-compose.dev.yml new file mode 100644 index 0000000..62bc990 --- /dev/null +++ b/drive-health/docker-compose.dev.yml @@ -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 \ No newline at end of file diff --git a/drive-health/docker-compose.prod.yml b/drive-health/docker-compose.prod.yml new file mode 100644 index 0000000..bb00926 --- /dev/null +++ b/drive-health/docker-compose.prod.yml @@ -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 \ No newline at end of file diff --git a/drive-health/go.mod b/drive-health/go.mod new file mode 100644 index 0000000..e45c300 --- /dev/null +++ b/drive-health/go.mod @@ -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 +) diff --git a/drive-health/go.sum b/drive-health/go.sum new file mode 100644 index 0000000..da6a1ef --- /dev/null +++ b/drive-health/go.sum @@ -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= diff --git a/drive-health/lib/config/config.go b/drive-health/lib/config/config.go new file mode 100644 index 0000000..2794b37 --- /dev/null +++ b/drive-health/lib/config/config.go @@ -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 +} diff --git a/drive-health/lib/hardware/logic.go b/drive-health/lib/hardware/logic.go new file mode 100644 index 0000000..afad9b1 --- /dev/null +++ b/drive-health/lib/hardware/logic.go @@ -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" +} diff --git a/drive-health/lib/hardware/models.go b/drive-health/lib/hardware/models.go new file mode 100644 index 0000000..cebf906 --- /dev/null +++ b/drive-health/lib/hardware/models.go @@ -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 +} diff --git a/drive-health/lib/svc/service.go b/drive-health/lib/svc/service.go new file mode 100644 index 0000000..3c4c76c --- /dev/null +++ b/drive-health/lib/svc/service.go @@ -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 +} diff --git a/drive-health/lib/svc/service_cleanup.go b/drive-health/lib/svc/service_cleanup.go new file mode 100644 index 0000000..79261ac --- /dev/null +++ b/drive-health/lib/svc/service_cleanup.go @@ -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) + } + } + }() +} diff --git a/drive-health/lib/web/api.go b/drive-health/lib/web/api.go new file mode 100644 index 0000000..fd506b1 --- /dev/null +++ b/drive-health/lib/web/api.go @@ -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, + }) + }) + +} diff --git a/drive-health/lib/web/auth_middleware.go b/drive-health/lib/web/auth_middleware.go new file mode 100644 index 0000000..ce27d00 --- /dev/null +++ b/drive-health/lib/web/auth_middleware.go @@ -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) +} diff --git a/drive-health/lib/web/frontend.go b/drive-health/lib/web/frontend.go new file mode 100644 index 0000000..f0f65cf --- /dev/null +++ b/drive-health/lib/web/frontend.go @@ -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(), + }) + }) +} diff --git a/drive-health/lib/web/health.go b/drive-health/lib/web/health.go new file mode 100644 index 0000000..adcd54a --- /dev/null +++ b/drive-health/lib/web/health.go @@ -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", + }) + }) +} diff --git a/drive-health/lib/web/net.go b/drive-health/lib/web/net.go new file mode 100644 index 0000000..5e667d9 --- /dev/null +++ b/drive-health/lib/web/net.go @@ -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 +} diff --git a/drive-health/main.go b/drive-health/main.go new file mode 100644 index 0000000..886aeaa --- /dev/null +++ b/drive-health/main.go @@ -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") +} diff --git a/drive-health/media/design_v1.webp b/drive-health/media/design_v1.webp new file mode 100644 index 0000000..9f4434a Binary files /dev/null and b/drive-health/media/design_v1.webp differ diff --git a/drive-health/media/old-look.png b/drive-health/media/old-look.png new file mode 100644 index 0000000..192f7b2 Binary files /dev/null and b/drive-health/media/old-look.png differ diff --git a/drive-health/static/main.js b/drive-health/static/main.js new file mode 100644 index 0000000..56d815a --- /dev/null +++ b/drive-health/static/main.js @@ -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); +} diff --git a/drive-health/static/style.css b/drive-health/static/style.css new file mode 100644 index 0000000..afae1d9 --- /dev/null +++ b/drive-health/static/style.css @@ -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; + +} \ No newline at end of file diff --git a/drive-health/templates/index.html b/drive-health/templates/index.html new file mode 100644 index 0000000..bd9707a --- /dev/null +++ b/drive-health/templates/index.html @@ -0,0 +1,121 @@ + + + + + + + Drive Health Dashboard + + +{{ $older := .older }} +{{ $newer := .newer }} + + +
+ +
+
+

Available Disks

+
+
+ +
+
+ {{ if len .drives }} + + + + + + + + + + + + {{ range .drives }} + {{ $temp := .GetTemperature }} + + + + + + + + {{ if gt $temp 50 }} + + {{ else if gt $temp 30 }} + + {{ else }} + + {{ end }} + + {{ end }} + +
IDNameModelSerialTemperature
#{{ .ID }} {{ .Name }} {{ .Model }} {{ .Serial }}{{ $temp }}°C{{ $temp }}°C{{ $temp }}°C
+ {{ else }} +

No hard drives found.

+ {{ end }} +
+
+ +
+ +
+ +
+
+

Temperature Graph

+
+
+
+
+ + +
+
+ + + +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ + + {{ if len .drives }} + {{ range .drives }} +
+
/dev/{{.Name}}
+

{{.Model}}:{{.HWID}} {{.Size}}

+ + {{ .Model }} Image + +
+
+ {{ end }} + {{ else }} +

No hard drives found.

+ {{ end }} +
+
+ +
+ + + +