From ce011bd2368394255b346baf776a4889d8b77ac7 Mon Sep 17 00:00:00 2001 From: matt Date: Wed, 14 Oct 2026 00:39:13 -0700 Subject: [PATCH] removed python api from copied dashboard --- README.md | 5 +- web_dash/Dockerfile | 8 - web_dash/apis/StorageSummary/Helpers.py | 66 ------- web_dash/apis/StorageSummary/Routes.py | 157 --------------- web_dash/apis/StorageSummary/Storage.py | 178 ------------------ .../__pycache__/Helpers.cpython-313.pyc | Bin 3428 -> 0 bytes .../__pycache__/Routes.cpython-313.pyc | Bin 5538 -> 0 bytes .../__pycache__/Storage.cpython-313.pyc | Bin 7493 -> 0 bytes web_dash/apis/StorageSummary/app.py | 14 -- web_dash/apis/StorageSummary/requirements.txt | 7 - web_dash/supervisord.conf | 11 -- web_dash/web/proxy/nginx.conf | 40 ---- 12 files changed, 2 insertions(+), 484 deletions(-) delete mode 100644 web_dash/apis/StorageSummary/Helpers.py delete mode 100644 web_dash/apis/StorageSummary/Routes.py delete mode 100644 web_dash/apis/StorageSummary/Storage.py delete mode 100644 web_dash/apis/StorageSummary/__pycache__/Helpers.cpython-313.pyc delete mode 100644 web_dash/apis/StorageSummary/__pycache__/Routes.cpython-313.pyc delete mode 100644 web_dash/apis/StorageSummary/__pycache__/Storage.cpython-313.pyc delete mode 100644 web_dash/apis/StorageSummary/app.py delete mode 100644 web_dash/apis/StorageSummary/requirements.txt diff --git a/README.md b/README.md index 4e23b4c..38bd8fb 100644 --- a/README.md +++ b/README.md @@ -167,10 +167,10 @@ There are several primary API endpoints with varying levels of details. ``` ## File Structure -The Python code is split across multiple files with different files having code for different areas of need. +The Python API code is split across multiple files with different files having code for different areas of need. ``` -ble-scanner/ +api/ ├─ app.py # Flask + Scheduler + API ├─ ble.py # BLE helper functions to read temp+humidity data ├─ scanner.py # Async BLE scan + cache logic @@ -179,5 +179,4 @@ ble-scanner/ ├─ run.sh # Run API from shell ├─ scanner.py # Handles scanning for Mii sensors, other helper functions ├─ sensors.py # Object classifier -└─ README.md ``` diff --git a/web_dash/Dockerfile b/web_dash/Dockerfile index 8b8bf75..4784c59 100644 --- a/web_dash/Dockerfile +++ b/web_dash/Dockerfile @@ -38,14 +38,6 @@ COPY web/html/ /var/www/html/ RUN rm -rf /etc/nginx/sites-enabled/default COPY web/proxy/nginx.conf /etc/nginx/conf.d/default.conf -# DriveHealth on 5001 -RUN mkdir -p /opt/DriveHealth -RUN python3 -m venv /opt/DriveHealth/venv -COPY apis/StorageSummary/requirements.txt /opt/DriveHealth/ -RUN /opt/DriveHealth/venv/bin/pip3 install --no-cache-dir -r /opt/DriveHealth/requirements.txt -# Copy the actual app code after installing deps -COPY apis/StorageSummary/ /opt/DriveHealth/ - # Add supervisord configuration COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf diff --git a/web_dash/apis/StorageSummary/Helpers.py b/web_dash/apis/StorageSummary/Helpers.py deleted file mode 100644 index 9080822..0000000 --- a/web_dash/apis/StorageSummary/Helpers.py +++ /dev/null @@ -1,66 +0,0 @@ - -import base64, hashlib -import subprocess -import ipaddress -from typing import Dict, Any, List - -# pickle subroutines -import pickle -from pathlib import Path - -print("Importing Helpers") -# subnet helper app -def is_ip_in_subnets(ip, subnet): - try: - ip_obj = ipaddress.IPv4Address(ip) - subnet_obj = ipaddress.IPv4Network(subnet) - if ip_obj in subnet_obj: - return True - return False - except ValueError as e: - # If the IP address is not valid, raise an error - return False - -# subroutine to run a command, return stdout as array unless zero_only then return [0] -def run_command(cmd, zero_only=False, use_shell=True, req_check = True): - # Run the command and capture the output - result = subprocess.run(cmd, shell=use_shell, check=req_check, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - # Decode the byte output to a string - output = result.stdout.decode('utf-8') - # Split the output into lines and store it in an array - output_lines = [line for line in output.split('\n') if line] - # Return result - try: - return output_lines[0] if zero_only else output_lines - except: - return output_lines - -def short_uuid(value: str, length=8): - hasher = hashlib.md5() - hasher.update(value.encode('utf-8')) - full_hex = hasher.hexdigest() - return full_hex[:length] - -# test subroutine -def get_hostname(): - hostname_command = "hostname" - return run_command(hostname_command, zero_only = True) - - -# pickle helpers -# Where the pickled state will live -STATE_FILE = Path(__file__).parent / "storage_api_state.pkl" - -def save_state(obj: object, path: Path | str = STATE_FILE) -> None: - path = Path(path) - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("wb") as f: - pickle.dump(obj, f, protocol=pickle.HIGHEST_PROTOCOL) - print("Pickle saved") - -def load_state(path: Path | str = STATE_FILE) -> object | None: - path = Path(path) - if path.is_file(): - with path.open("rb") as f: - return pickle.load(f) - return None \ No newline at end of file diff --git a/web_dash/apis/StorageSummary/Routes.py b/web_dash/apis/StorageSummary/Routes.py deleted file mode 100644 index a574430..0000000 --- a/web_dash/apis/StorageSummary/Routes.py +++ /dev/null @@ -1,157 +0,0 @@ -# flask routes for storage summary API - -# import external libraries -from flask import Flask, jsonify, request, Response, abort -#from flask_apscheduler import APScheduler -from typing import Dict, Union -import json, time, redis, yaml, datetime -import secrets, string -import requests -from requests import RequestException, Response - -# import needed Class Libraries -from Storage import * -from Helpers import * - -SummaryServer = load_state() - -if SummaryServer is None: - SummaryServer = DriveHealthServer(get_hostname()) - print("Created new SummaryServer") - -# declare flask app -app = Flask(__name__) - -############################ -# Flask routes -############################ - -# client update -@app.route('/storage_client_update', methods=['POST']) -def storage_client_update(): - payload = request.get_json(silent=False) - if payload is None: - abort(400, description="Request body must be valid JSON") - payload["IP Address"] = request.remote_addr - # offload processing to helper - processed_payload = client_update_helper(payload) - return jsonify(processed_payload), 200 - -# remove client -@app.route('/storage_client_delete', methods=['POST']) -def storage_client_delete(): - payload = request.get_json(silent=False) - print(payload) - if payload is None: - abort(400, description="Request body must be valid JSON") - result = client_remove_helper(payload) - print(result) - return jsonify(result) - -# client details -@app.route('/client_details', methods=['GET']) -def client_details(): - result = [] - for client in SummaryServer.clients: - result.append(client.get_details()) - return jsonify(result) - -# client summary -@app.route('/client_summary', methods=['GET']) -def client_summary(): - result = [] - for client in SummaryServer.clients: - result.append(client.get_summary()) - return jsonify(result) - -# client brief summary -@app.route('/brief_summary', methods=['GET']) -def brief_summary(): - result = [] - for client in SummaryServer.clients: - result.append(f"{client.name} at {client.ip} - {len(client.drives)} drives") - return jsonify({ - "message": "Brief Summary", - "result": result - }) - -# test route -@app.route('/test', methods=['GET']) -def test_route(): - return jsonify({ - "message": "Hello world!", - "hostname": get_hostname(), - "DriveHealthServer": f"{SummaryServer}" - }) - -# test route 2 -@app.route('/test_storage_summary', methods=['GET']) -def test_storage_summary(): - return test_route() - - -############################ -# Route Helpers -############################ - -# helper function for client_update route -# handles the submission data from the flask route -def client_update_helper(payload: dict): - result = None - required_keys = {"hostname", "API_KEY", "drives", "IP Address"} - # check json structure and API key - processed_payload = post_processor(payload, required_keys) - # add or update the client - result = client_processor(processed_payload) - return result - -# client processing function, add/update logic in Class Methods -def client_processor(client_dict: dict): - result = SummaryServer.process_client_data(client_dict) - save_state(SummaryServer) - return result - -# handle submission from remove route -def client_remove_helper(payload: dict): - result = None - required_keys = {"remove_hosts", "API_KEY"} - # check the submission data - processed_payload = post_processor(payload, required_keys) - result = SummaryServer.remove_client(processed_payload["remove_hosts"]) - return result - -# this function takes the raw POST input from client_update and makes sure it is valid and returns it if so -def post_processor(client_dict: dict, required_keys: dict): - payload_safe = False - keys_present = False - api_valid = False - api_key = "deadbeef" - # check for keys - missing = required_keys - client_dict.keys() - if not missing: - keys_present = True - else: - return { - "message": f"error - {missing} keys missing" - } - # check API - if client_dict["API_KEY"] == api_key: - api_valid = True - - # if both then safe - if keys_present and api_valid: - payload_safe = True - - # add a key to indicate this was processed - client_dict["processed_at"] = time.time() - return client_dict - -############################ -# Main function -############################ - -def run_main(): - - # Flask API - app.run(debug=False, host='0.0.0.0', port=5001) - diff --git a/web_dash/apis/StorageSummary/Storage.py b/web_dash/apis/StorageSummary/Storage.py deleted file mode 100644 index 88d904a..0000000 --- a/web_dash/apis/StorageSummary/Storage.py +++ /dev/null @@ -1,178 +0,0 @@ -# Class definitions for storage summary -# Classes needed: -### DriveHealthServer - this will be the list of remote server objects and functions for interacting with them -### DriveHealthClient - this will be the remote client class where all the drives are - -from typing import List, Mapping, Any, Sequence, Dict -from Helpers import * -print("Importing Storage Class") - -################################################################# -### DriveHealthServer Class -### This is the server objext -################################################################# - -class DriveHealthServer: - - # create server object for local cache of remote clients - def __init__(self, hostname: str): - # the system needs a name, should be equal to the uuid of the client - self.name = hostname - self.short_id = short_uuid(self.name) - self.hostname = hostname - # system contains an array of CosmostatClient Objects - self.clients = [] - - def __str__(self): - self_string = f"DriveHealthServer {self.name} - {self.short_id}" - return self_string - - def __repr__(self): - self_string = f"DriveHealthServer {self.name} - {self.short_id}" - - def __del__(self): - print("Deleting Server") - - # either add or update client - def process_client_data(self, client_data: dict): - result = None - if self.check_for_uuid(self.calculate_uuid(client_data)): - result = { - "message": "Updating client.", - "summary": self.update_client(client_data) - } - else: - result = { - "message": "Creating new client", - "summary": self.add_client(client_data) - } - return result - - def add_client(self, client_data: dict): - new_client = DriveHealthClient(client_data) - self.clients.append(new_client) - return new_client.get_summary() - - def update_client(self, client_data): - result = self.get_client(self.calculate_uuid(client_data)) - result.update_client(client_data) - return result.get_summary() - - def get_client(self, client_uuid: str): - result = None - for client in self.clients: - if client.short_id == client_uuid: - result = client - return result - - def remove_client(self, client_uuid: str | list[str]): - result = None - old_clients = self.clients - temp_clients = [] - purged_clients = [] - for client in old_clients: - if client.short_id in client_uuid: - purged_clients.append(client) - else: - temp_clients.append(client) - self.clients = temp_clients - result = { - "message": "client removal complete", - #"clients_removed": purged_clients, - #"new_client_count": len(self.clients), - #"old_client_count": len(old_clients) - } - return result - - def check_for_uuid(self, uuid: str): - result = False - for client in self.clients: - if client.short_id == uuid: - result = True - return result - - # calculate uuid based on same parameters - def calculate_uuid(self, client_data): - unique_string = f"{client_data["hostname"]} - {client_data["IP Address"]}" - return short_uuid(unique_string) - - -################################################################# -### DriveHealthClient Class -### These are the actual remote clients -################################################################# - -class DriveHealthClient: - - ############################################################ - # instantiate new DriveHealthClient - ############################################################ - - def __init__(self, client_data: dict): - # the system needs a name, should be equal to the uuid of the client - self.client_data = client_data - self.ip = self.client_data["IP Address"] - self.name = self.client_data["hostname"] - self.data_timestamp = self.client_data["processed_at"] - self._unique_string = f"{self.name} - {self.ip}" - self.short_id = short_uuid(self._unique_string) - self.drives = self.client_data["drives"] - - - def __str__(self): - self_string = f"DriveHealthClient Server {self.name} - {self.short_id}" - return self_string - - def __repr__(self): - self_string = f"DriveHealthClient Server {self.name} - {self.short_id}" - - def __del__(self): - print("Deleting Client") - - def get_summary(self): - drives_brief = [] - for drive in self.drives: - drives_brief.append({ - "serial": drive["Serial Number"], - "model": drive["Model"], - "capacity": drive["Disk Size"] - }) - result = { - "name": self.name, - "hostname": self.ip, - "uuid": self.short_id, - "drives": drives_brief - } - return result - - def get_details(self): - drive_details = [] - for drive in self.drives: - drive_details.append({ - "disk_id": drive["Disk ID"], - "serial": drive["Serial Number"], - "health_status": drive["Health Status"], - "model": drive["Model"], - "capacity": drive["Disk Size"], - "power_on_hours": drive["Power On Hours"], - "power_on_count": drive["Power On Count"], - "host_writes": drive["Host Writes"], - "wear_level": drive["Wear Level Count"], - "drive_letter": drive["Drive Letter"], - "drive_interface": drive["Interface"], - "transfer_mode" : drive["Transfer Mode"] - }) - result = { - "name": self.name, - "ip": self.ip, - "uuid": self.short_id, - "timestamp": self.data_timestamp, - "drives": drive_details - } - return result - - def update_client(self, client_data: dict): - result = None - self.data_timestamp = client_data["processed_at"] - self.drives = client_data["drives"] - return result diff --git a/web_dash/apis/StorageSummary/__pycache__/Helpers.cpython-313.pyc b/web_dash/apis/StorageSummary/__pycache__/Helpers.cpython-313.pyc deleted file mode 100644 index bfe58fe59416f36ce6799b541e20e110b9c57925..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3428 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 diff --git a/web_dash/apis/StorageSummary/__pycache__/Storage.cpython-313.pyc b/web_dash/apis/StorageSummary/__pycache__/Storage.cpython-313.pyc deleted file mode 100644 index 60b39a474264c34892247119819e79e0ae89796d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7493 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