From 650b48146359dc8876c3d5116b719ad1c9ec7f9f Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 2 Nov 2025 13:11:42 -0800 Subject: [PATCH] Init commit --- defaults/main.yaml | 31 +++++ files/dashboard/index.php | 87 +++++++++++++ files/dashboard/styles.css | 80 ++++++++++++ files/img2txt.py | 54 ++++++++ files/scripts/app.py | 54 ++++++++ files/scripts/store_drive.sh | 210 ++++++++++++++++++++++++++++++ files/scripts/test.sh | 66 ++++++++++ tasks/autologin.yaml | 49 +++++++ tasks/dashboard.yaml | 50 +++++++ tasks/drive_index.yaml | 95 ++++++++++++++ tasks/hello_there.yaml | 31 +++++ tasks/main.yaml | 21 +++ tasks/user_setup.yaml | 37 ++++++ templates/docker-compose-php.yaml | 12 ++ templates/drive_check.sh | 91 +++++++++++++ templates/drive_index.service | 15 +++ 16 files changed, 983 insertions(+) create mode 100644 defaults/main.yaml create mode 100644 files/dashboard/index.php create mode 100644 files/dashboard/styles.css create mode 100644 files/img2txt.py create mode 100644 files/scripts/app.py create mode 100644 files/scripts/store_drive.sh create mode 100644 files/scripts/test.sh create mode 100644 tasks/autologin.yaml create mode 100644 tasks/dashboard.yaml create mode 100644 tasks/drive_index.yaml create mode 100644 tasks/hello_there.yaml create mode 100644 tasks/main.yaml create mode 100644 tasks/user_setup.yaml create mode 100644 templates/docker-compose-php.yaml create mode 100644 templates/drive_check.sh create mode 100644 templates/drive_index.service diff --git a/defaults/main.yaml b/defaults/main.yaml new file mode 100644 index 0000000..4e65b5a --- /dev/null +++ b/defaults/main.yaml @@ -0,0 +1,31 @@ +--- + +# required packages +ssd_health_packages: + - smartmontools + - python3-docker + - python3-packaging + - python3-venv + - sqlite3 + +# autologin vars +autologin_password: "kingduy" +autologin: true +autologin_user: "ssd_health" + +# php container vars +container_name: "ssd_dashboard" +container_http_port: "8088" +extra_volumes: "" + +# api service vars +service_name: "drive_index" +service_folder: "/opt/ssd_health" + +# other vars +db_path: "{{ service_folder }}/drive_records.db" +hello_there_url: "https://docs.theregion.beer/hello-there.png" +sector_size: "512" +install_kiosk: false + +... \ No newline at end of file diff --git a/files/dashboard/index.php b/files/dashboard/index.php new file mode 100644 index 0000000..5e8f287 --- /dev/null +++ b/files/dashboard/index.php @@ -0,0 +1,87 @@ + [ + 'header' => "Content-type: application/json\r\n", + 'method' => 'GET', + ], + ]; + + $context = stream_context_create($options); + $result = file_get_contents($url, false, $context); + + if ($result === FALSE) { + die('Error Fetching data'); + } + + return json_decode($result, true); // Decode JSON as an associative array +} +?> + + + + + SSD Health Dashboard + + + +
+

SSD Health Dashboard

+ '; + foreach ($ssdData as $ssd): + if ($i % 2 == 0) { + echo '
'; + } + echo << + + + + + + + + +
+ Disk ID: + + {$ssd['id']} +
+ Model String: + + {$ssd['model']} +
+ Serial Number: + + {$ssd['serial']} +
+ TB Written: + + {$ssd['TBW']} +
+ Disk Capacity: + + {$ssd['capacity']} +
+ Disk Flavor: + + {$ssd['flavor']} +
+ SMART Result: + + {$ssd['smart']} +
+
+ EOL; + $i++; + endforeach; + echo ''; + ?> + + + \ No newline at end of file diff --git a/files/dashboard/styles.css b/files/dashboard/styles.css new file mode 100644 index 0000000..b31e06c --- /dev/null +++ b/files/dashboard/styles.css @@ -0,0 +1,80 @@ +/* styles.css */ + +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + background-color: #2c3e50; /* Dark background color */ + color: #bdc3c7; /* Dimmer text color */ +} + +.hidden-info { + display: none; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 20px; + background-color: #34495e; /* Darker background for container */ + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); /* Slightly darker shadow */ + margin-top: 20px; +} + +h1, h2, h3, h4 { + color: #bdc3c7; /* Dimmer text color */ +} + +ul { + list-style-type: none; + padding: 0; +} + +li { + margin-bottom: 10px; + color: #bdc3c7; /* Dimmer text color */ +} + +.group-columns { + display: flex; +} + +.group-rows { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; /* Left justification */ + margin-top: 10px; +} + +.group-column { + flex: 0 0 calc(33% - 10px); /* Adjust width of each column */ +} + +.column { + flex: 1; + padding: 0 10px; /* Adjust spacing between columns */ +} + +.subcolumn { + margin-left: 10px; +} + +.grid { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + margin-top: 5px; +} + +.meter { + width: calc(90% - 5px); + max-width: calc(45% - 5px); + margin-bottom: 5px; + border: 1px solid #7f8c8d; /* Light border color */ + border-radius: 5px; + padding: 5px; + text-align: center; + background-color: #2c3e50; /* Dark background for meter */ +} + diff --git a/files/img2txt.py b/files/img2txt.py new file mode 100644 index 0000000..648fc5d --- /dev/null +++ b/files/img2txt.py @@ -0,0 +1,54 @@ +""" +@author: Viet Nguyen +""" +import argparse + +import cv2 +import numpy as np + + +def get_args(): + parser = argparse.ArgumentParser("Image to ASCII") + parser.add_argument("--input", type=str, default="data/input.jpg", help="Path to input image") + parser.add_argument("--output", type=str, default="data/output.txt", help="Path to output text file") + parser.add_argument("--mode", type=str, default="complex", choices=["simple", "complex"], + help="10 or 70 different characters") + parser.add_argument("--num_cols", type=int, default=150, help="number of character for output's width") + args = parser.parse_args() + return args + + +def main(opt): + if opt.mode == "simple": + CHAR_LIST = '@%#*+=-:. ' + else: + CHAR_LIST = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,\"^`'. " + num_chars = len(CHAR_LIST) + num_cols = opt.num_cols + image = cv2.imread(opt.input) + image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + height, width = image.shape + cell_width = width / opt.num_cols + cell_height = 2 * cell_width + num_rows = int(height / cell_height) + if num_cols > width or num_rows > height: + print("Too many columns or rows. Use default setting") + cell_width = 6 + cell_height = 12 + num_cols = int(width / cell_width) + num_rows = int(height / cell_height) + + output_file = open(opt.output, 'w') + for i in range(num_rows): + for j in range(num_cols): + output_file.write( + CHAR_LIST[min(int(np.mean(image[int(i * cell_height):min(int((i + 1) * cell_height), height), + int(j * cell_width):min(int((j + 1) * cell_width), + width)]) * num_chars / 255), num_chars - 1)]) + output_file.write("\n") + output_file.close() + + +if __name__ == '__main__': + opt = get_args() + main(opt) \ No newline at end of file diff --git a/files/scripts/app.py b/files/scripts/app.py new file mode 100644 index 0000000..e9dde59 --- /dev/null +++ b/files/scripts/app.py @@ -0,0 +1,54 @@ +from flask import Flask, jsonify, request +import sqlite3 +import json + +app = Flask(__name__) + +# Function to get all drive records from the database +def get_all_drive_records(): + conn = sqlite3.connect('drive_records.db') + cursor = conn.cursor() + cursor.execute("SELECT * FROM drive_records") + rows = cursor.fetchall() + conn.close() + + drives = [] + for row in rows: + drive = { + 'id': row[0], + 'serial': row[1], + 'model': row[2], + 'flavor': row[3], + 'capacity': row[4], + 'TBW': row[5], + 'smart': row[6] + } + drives.append(drive) + return jsonify(drives) + +# Function to check if a serial number exists in the database +def check_serial_exists(serial): + conn = sqlite3.connect('drive_records.db') + cursor = conn.cursor() + cursor.execute("SELECT * FROM drive_records WHERE serial=?", (serial,)) + row = cursor.fetchone() + conn.close() + return bool(row) + +# Route to check if a serial number exists in the database +@app.route('/check', methods=['GET']) +def check(): + serial_lookup = request.args.get('serial_lookup') + if not serial_lookup: + return jsonify({'error': 'No serial number provided'}), 400 + + exists = check_serial_exists(serial_lookup) + return jsonify({'serial_number_exists': exists}) + +# Route to get all drive records in JSON format +@app.route('/drives', methods=['GET']) +def index(): + return get_all_drive_records() + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/files/scripts/store_drive.sh b/files/scripts/store_drive.sh new file mode 100644 index 0000000..597bfd8 --- /dev/null +++ b/files/scripts/store_drive.sh @@ -0,0 +1,210 @@ +#!/bin/bash +# script for handling adding and updating local drive database + +# Function to display usage +usage() { + echo "Usage: $0 [-i] [-v] [-x] -d /path/to/drive_records.db [-a 'serial,model,flavor,capacity,TBW,smart' OR -u 'serial,TBW,smart'] " + echo "Options - choose only one of a, u, or i, v and x are optional and not exclusive, and always provide the d" + echo " -d /path/to/drive_records.db Specify path to DB, required" + echo " -a 'serial,model,flavor,capacity,TBW,smart' Add new drive to sqlite db" + echo " -u 'serial,TBW,smart' Update drive data in sqlite db" + echo " -i Initialize database if not present" + echo " -v Output verbose information" + echo " -x Output debug information" + exit 1 +} + +# init_db subroutine +init_db() { + if [ "$BE_VERBOSE" == "true" ] ; then + echo "initializing database" + fi + # Check if the file does not exist + if [ ! -e "$DB_FILE" ]; then + if [ "$BE_VERBOSE" == "true" ] ; then + echo "No database file, initializing at $DB_FILE" + fi + sqlite3 "$DB_FILE" "$CREATE_TABLE" + chmod 666 $DB_FILE + else + if [ "$BE_VERBOSE" == "true" ] ; then + ls -lah $DB_FILE + echo "Database file exists, checking tables." + fi + DB_PRESENT=$(sqlite3 $DB_FILE .tables | grep drive_records) + if [ -z "$DB_PRESENT" ]; then + sqlite3 "$DB_FILE" "$CREATE_TABLE" + chmod 666 $DB_FILE + fi + fi +} + +# Define variables +DB_FILE="drive_records.db" +NEEDS_ARGS=false +INIT_ONLY=false +ADD_DRIVE=false +UPDATE_DRIVE=false +DB_PROVIDED=false +VALID_FLAGS=false +BE_VERBOSE=false +OUTPUT_DEBUG=false +CREATE_TABLE="CREATE TABLE drive_records ( + id INTEGER PRIMARY KEY, + serial TEXT NOT NULL, + model TEXT NOT NULL, + flavor TEXT NOT NULL, + capacity TEXT NOT NULL, + TBW TEXT NOT NULL, + smart TEXT NOT NULL +);" + +# Parse command line options +while getopts ":d:a:u:ivx" opt; do + case ${opt} in + v ) # process option v + echo "Be Verbose" + BE_VERBOSE=true + ;; + x ) # process option x + echo "Debug Info Enabled" + OUTPUT_DEBUG=true + ;; + d ) # process option d + if [ "$BE_VERBOSE" == "true" ] ; then + echo "database path provided" + fi + DB_PROVIDED=true + DB_FILE=$OPTARG + ;; + a ) # process option a + if [ "$BE_VERBOSE" == "true" ] ; then + echo "add new drive" + fi + ADD_DRIVE=true + NEEDS_ARGS=true + VALID_FLAGS=true + DRIVE_DATA=$OPTARG + ;; + u ) # process option u + if [ "$BE_VERBOSE" == "true" ] ; then + echo "update existing drive" + fi + UPDATE_DRIVE=true + NEEDS_ARGS=true + VALID_FLAGS=true + DRIVE_DATA=$OPTARG + ;; + i ) # process option i + if [ "$BE_VERBOSE" == "true" ] ; then + echo "initialize database" + fi + VALID_FLAGS=true + init_db + ;; + \? ) usage + ;; + esac +done +shift $((OPTIND -1)) + +# Check if all required options are provided +if [ "$DB_PROVIDED" == "false" ] ; then + if [ "$BE_VERBOSE" == "true" ] ; then + echo "Database Not Provided" + fi + usage +fi +if [ "$VALID_FLAGS" == "false" ] ; then + if [ "$BE_VERBOSE" == "true" ] ; then + echo "Invalid Flags" + fi + usage +fi +if [ "$NEEDS_ARGS" == "true" ] ; then + if [ "$BE_VERBOSE" == "true" ] ; then + echo "NEEDS_ARGS: $NEEDS_ARGS" + fi + if [ -z "$DRIVE_DATA" ] ; then + if [ "$BE_VERBOSE" == "true" ] ; then + echo "Missing Arguments" + fi + usage + fi +fi + +# add new drive +if [ "$ADD_DRIVE" == "true" ]; then + if [ "$BE_VERBOSE" == "true" ] ; then + echo "Adding new drive" + fi + if [ "$OUTPUT_DEBUG" == "true" ] ; then + echo "DRIVE_DATA for new drive:" + echo "$DRIVE_DATA" + fi + # Extract the values from the argument string + IFS=',' read -ra data <<< "$DRIVE_DATA" + # Check if we have exactly 6 arguments + if [ ${#data[@]} -ne 6 ]; then + echo "Exactly 6 arguments are required." + exit 1 + fi + # Check if the file does not exist, this should never fail + if [ ! -e "$DB_FILE" ]; then + if [ "$BE_VERBOSE" == "true" ] ; then + echo "No database file, exiting" + exit 2 + fi + fi + DRIVE_EXISTS=$(curl -s http://0.0.0.0:5000/check?serial_lookup=${data[0]} | jq .serial_number_exists) + if [ "$DRIVE_EXISTS" == "false" ]; then + # Insert the values into the database + sqlite3 "$DB_FILE" <&2 + usage + ;; + esac +done +shift $((OPTIND -1)) + +# Check if the database file exists, otherwise create it and initialize the table +DB_FILE="drive_records.db" +if [ ! -f "$DB_FILE" ]; then + sqlite3 "$DB_FILE" < /dev/null +while true; do + clear + # get all disks + DISK_LIST=$(ls -lo /dev/sd? | awk '{print $9}') + # process each disk + IFS=$'\n' read -rd '' -a DISK_ARRAY <<< "$DISK_LIST" + for DISK in "${DISK_ARRAY[@]}"; do + # store smartctl data once + SMART_DATA=$(smartctl -x $DISK) + NVME_CHECK=$(echo "$SMART_DATA" | grep "NVMe Version") + SSD_CHECK=$(echo "$SMART_DATA" | grep "Rotation Rate" | grep "Solid State") + # if either SATA SSD or NVMe + if [ -n "$NVME_CHECK" ] || [ -n "$SSD_CHECK" ]; then + BLOCK_SIZE=$(fdisk -l $DISK | grep 'Sector size' | awk '{print $4}' ) + # SATA Logic + if [ -n "$SSD_CHECK" ] ; then + # Set Variables + TBW=$(echo "$SMART_DATA" | grep "Logical Sectors Written" | \ + awk -v BLOCK_SIZE="$BLOCK_SIZE" '{print $4 * BLOCK_SIZE / (2 ^ 40)}') + PLR=$(echo "$SMART_DATA" | grep Percent_Lifetime_Remain | awk '{print $4}') + CAPACITY=$(echo "$SMART_DATA" | grep "User Capacity" | cut -d '[' -f 2 | sed 's/]//g') + SERIAL=$(echo "$SMART_DATA" | grep "Serial Number" | cut -d ":" -f 2 | xargs) + MODEL=$(echo "$SMART_DATA" | grep "Device Model" | cut -d ":" -f 2 | xargs) + SMART=$(echo "$SMART_DATA" | grep "self-assessment test result" | cut -d ":" -f 2 | xargs) + FLAVOR="SATA SSD" + DRIVE_EXISTS=$(curl -s 0.0.0.0:5000/check?serial_lookup=$SERIAL | jq .serial_number_exists) + # Display drive data + echo "============ $DISK Disk Info - SSD: ============" + #echo "DRIVE_EXISTS: $DRIVE_EXISTS" + echo "Serial Number: $SERIAL" + echo "Model String: $MODEL" + echo "SMART Check: $SMART" + echo "Disk capacity: $CAPACITY" + echo "TB Written: $TBW TB" + if [ -z "$PLR" ] ; then + echo "Percent Lifetime Remaining data not available" + else + echo "$DISK has $PLR% lifetime remaining" + fi + echo + # database handler + if [ "$DRIVE_EXISTS" == "false" ] ; then + #echo "{{ service_folder }}/store_drive.sh -a '$SERIAL,$MODEL,$FLAVOR,$CAPACITY,$TBW,$SMART' -d {{ db_path }}" + {{ service_folder }}/store_drive.sh -a "$SERIAL,$MODEL,$FLAVOR,$CAPACITY,$TBW,$SMART" -d {{ db_path }} + else + #echo "{{ service_folder }}/store_drive.sh -u '$SERIAL,$TBW,$SMART' -d {{ db_path }}" + {{ service_folder }}/store_drive.sh -u "$SERIAL,$TBW,$SMART" -d {{ db_path }} + fi + # NVMe Logic + elif [ -n "$NVME_CHECK" ] ; then + # Set Variables + MODEL=$(echo "$SMART_DATA" | grep "Model Number" | cut -d ":" -f 2 | xargs) + SERIAL=$(echo "$SMART_DATA" | grep "Serial Number" | cut -d ":" -f 2 | xargs) + TBW=$(echo "$SMART_DATA" | grep "Data Units Written" | sed 's/,//g' | \ + awk -v BLOCK_SIZE="$BLOCK_SIZE" '{print $4 * BLOCK_SIZE / (2 ^ 30)}') + AVAIL_SPARE=$(echo "$SMART_DATA" | grep "Available Spare:" | cut -d ':' -f 2 | xargs) + CAPACITY=$(echo "$SMART_DATA" | grep "amespace 1 Size" | cut -d '[' -f 2 | sed 's/]//g') + SMART=$(echo "$SMART_DATA" | grep "self-assessment test result" | cut -d ":" -f 2 | xargs) + FLAVOR="NVMe" + DRIVE_EXISTS=$(curl -s 0.0.0.0:5000/check?serial_lookup=$SERIAL | jq .serial_number_exists) + # Display Disk Info + echo "============ $DISK Disk Info - NVMe: ============" + #echo "DRIVE_EXISTS: $DRIVE_EXISTS" + echo "Serial Number: $SERIAL" + echo "Model String: $MODEL" + echo "SMART Check: $SMART" + echo "Disk capacity: $CAPACITY" + echo "TB Written: $TBW TB" + echo "NAND spare blocks: $AVAIL_SPARE" + echo + # database handler + if [ "$DRIVE_EXISTS" == "false" ] ; then + #echo "{{ service_folder }}/store_drive.sh -a '$SERIAL,$MODEL,$FLAVOR,$CAPACITY,$TBW,$SMART' -d {{ db_path }}" + {{ service_folder }}/store_drive.sh -a "$SERIAL,$MODEL,$FLAVOR,$CAPACITY,$TBW,$SMART" -d {{ db_path }} + else + #echo "{{ service_folder }}/store_drive.sh -u '$SERIAL,$TBW,$SMART' -d {{ db_path }}" + {{ service_folder }}/store_drive.sh -u "$SERIAL,$TBW,$SMART" -d {{ db_path }} + fi + fi + else + echo "Skipping $DISK, not SATA SSD or NVMe" + fi + done + # wait 15 seconds, loop again + echo "Sleeping 15 seconds so you can read this" + sleep 15 +done diff --git a/templates/drive_index.service b/templates/drive_index.service new file mode 100644 index 0000000..a2484ab --- /dev/null +++ b/templates/drive_index.service @@ -0,0 +1,15 @@ + +[Unit] +Description={{ service_name }} API +After=network.target + +[Service] +User=root +Group=root +WorkingDirectory={{ service_folder }} +ExecStartPre=/bin/sleep 5 +ExecStart={{ service_folder }}/venv/bin/python {{ service_folder }}/app.py +Restart=always + +[Install] +WantedBy=multi-user.target