diff --git a/defaults/main.yaml b/defaults/main.yaml
index f35258e..c1f5790 100644
--- a/defaults/main.yaml
+++ b/defaults/main.yaml
@@ -57,6 +57,8 @@ REAL_API_KEY: "{{ lookup('password', '/dev/null length=64 chars=ascii_letters,di
# dashboard vars
service_control_web_folder: "{{ service_folder }}/web"
service_control_docker_folder: "{{ service_folder }}/docker"
+service_control_vizz_folder: "{{ service_folder }}/vizz"
+mcvizz_web_port: "81"
public_dashboard: true
custom_port: "80"
web_src: "/web"
@@ -83,4 +85,5 @@ local_api_address: "http://{{ cosmostat_server_ip }}:{{ custom_api_port }}/"
cosmostat_server_reporter: true
# setting this to false for default install
disable_local_dashboard: false
+update_pipeline: false
...
\ No newline at end of file
diff --git a/files/api/DriveServer.py b/files/api/DriveServer.py
new file mode 100644
index 0000000..9a269b9
--- /dev/null
+++ b/files/api/DriveServer.py
@@ -0,0 +1,4 @@
+# This is the class definition for the remote Storage Systems
+# There will be a StorageLinux and StorageWindows Class, as well as the LocalServer Class
+# The LocalServer class will mostly be a List of Storage server objects and Class functions for interacting with them
+# The actual Storage server Classes will mostly just be collections of variables
diff --git a/files/api/StorageApi.py b/files/api/StorageApi.py
new file mode 100644
index 0000000..b06fc69
--- /dev/null
+++ b/files/api/StorageApi.py
@@ -0,0 +1,6 @@
+### This file contains the flask routes for interfacing with the DriveServer objects
+### I need routes for adding/updating windows/linux hosts, as well as a query route
+### There needs to also be a Redis handler, meaning also that this will render with PHP
+### but have javascript to update if any Redis data happens
+### This won't happen a lot, but it will happen occasionally
+
diff --git a/files/api/app.py b/files/api/app.py
index 0ea4caa..e68a063 100644
--- a/files/api/app.py
+++ b/files/api/app.py
@@ -371,7 +371,8 @@ def build_inventory():
"cosmostat_server_reporter": "true",
"cosmostat_server": "false",
"secure_api": "false",
- "disable_local_dashboard": "true",
+ "disable_local_dashboard": "false",
+ "update_pipeline": "true",
"REAL_API_KEY": f"{cosmostat_settings['REAL_API_KEY']}"
},
}
diff --git a/files/api/descriptors.json b/files/api/descriptors.json
index a5dcda3..b04a64f 100644
--- a/files/api/descriptors.json
+++ b/files/api/descriptors.json
@@ -261,6 +261,6 @@
"Percent Full": "acpi -V | jc --acpi | jq '.[] | select(.type==\"Battery\") | .charge_percent'",
"State": "acpi -V | jc --acpi | jq '.[] | select(.type==\"Battery\") | .state'"
},
- "precheck": "acpi | grep Battery | wc -l"
+ "precheck": "acpi | grep Battery | grep -v unavailable | wc -l"
}
]
\ No newline at end of file
diff --git a/files/archive/index.php b/files/archive/index.php
new file mode 100644
index 0000000..49d05c2
--- /dev/null
+++ b/files/archive/index.php
@@ -0,0 +1,141 @@
+";
+$context = stream_context_create([
+ 'http' => [
+ 'timeout' => 5, // seconds
+ 'header' => "User-Agent: PHP/" . PHP_VERSION . "\r\n"
+ ]
+]);
+$json = @file_get_contents($apiUrl, false, $context);
+if ($json === false) {
+ die('
+ This dashboard shows the local Matt-Cloud system stats.
+
API
+
+
+
+
+ Component Desriptor
+
To view the component descriptor, you may
+ curl -s https://= h($_SERVER['SERVER_NAME']) ?>/descriptor
+ This will return the entire JSON descriptor variable.
+ The endpoint agent uses this descriptor to build out its local System Object.
+ The agent then reports back to the Cosmostat Server with all the data found in the descriptor.
+ Full Source Code can be found at its Gitea page.
+
+
+
+
+
+
+
+
+
System Properties
+
+
+
+
= h($prop['Property']) ?>
+
+
+
Live System Metrics
+
+
+ Connecting...
+
+
+
+
+
+
Toggle Component Details
+
+
+
+
+
Components
+
+
+
+
+
+
+
= h($comp['component_name']) ?>
+
+
= h($info) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tasks/server.yaml b/files/archive/server.yaml
similarity index 100%
rename from tasks/server.yaml
rename to files/archive/server.yaml
diff --git a/files/server/server.php b/files/archive/server/server.php
similarity index 99%
rename from files/server/server.php
rename to files/archive/server/server.php
index 33a9b13..1c3342f 100644
--- a/files/server/server.php
+++ b/files/archive/server/server.php
@@ -121,6 +121,7 @@ $selectedHost = $clients[$selectedIdx]['hostname'];
+ This dashboard shows the local Matt-Cloud system stats.
+
API
+
+
+
+ Component Desriptor
+
To view the component descriptor, you may
+ curl -s https://= h($_SERVER['SERVER_NAME']) ?>/descriptor
+ This will return the entire JSON descriptor variable.
+ The endpoint agent uses this descriptor to build out its local System Object.
+ The agent then reports back to the Cosmostat Server with all the data found in the descriptor.
+ Full Source Code can be found at its Gitea page.
+
This dashboard shows the local Matt-Cloud system stats.
+
API
+
+
+
+ Component Desriptor
+
To view the component descriptor, you may
+ curl -s https://= h($_SERVER['SERVER_NAME']) ?>/descriptor
+ This will return the entire JSON descriptor variable.
+ The endpoint agent uses this descriptor to build out its local System Object.
+ The agent then reports back to the Cosmostat Server with all the data found in the descriptor.
+ Full Source Code can be found at its Gitea page.
+
+
+
+
+
System Properties
+
+
+
+
+
= h($prop['Property']) ?>
+
+
+
+
+
Live System Metrics
+
Connecting...
+
+
+
+
Toggle Component Details
+
+
+
+
+
+
Components
+
+
+
+
= h($comp['component_name']) ?>
+
+
+
= h($info) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/files/docker/Dockerfile b/files/docker/Dockerfile
index c094f85..8b8bf75 100644
--- a/files/docker/Dockerfile
+++ b/files/docker/Dockerfile
@@ -6,10 +6,12 @@ FROM php:8.1-apache
RUN apt-get update && apt-get install -y --no-install-recommends \
# Services
redis-server nginx \
+ # Python
+ python3 python3-venv python3-pip \
# Process supervisor
supervisor \
# Others
- net-tools \
+ net-tools curl \
# Clean up
&& rm -rf /var/lib/apt/lists/*
@@ -36,6 +38,14 @@ 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/files/docker/apis/StorageSummary/Helpers.py b/files/docker/apis/StorageSummary/Helpers.py
new file mode 100644
index 0000000..9080822
--- /dev/null
+++ b/files/docker/apis/StorageSummary/Helpers.py
@@ -0,0 +1,66 @@
+
+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/files/docker/apis/StorageSummary/Routes.py b/files/docker/apis/StorageSummary/Routes.py
new file mode 100644
index 0000000..2fe819b
--- /dev/null
+++ b/files/docker/apis/StorageSummary/Routes.py
@@ -0,0 +1,176 @@
+# 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 = DriveHealthServer(get_hostname())
+
+SummaryServer = load_state()
+
+if SummaryServer is None:
+ SummaryServer = DriveHealthServer(get_hostname())
+ print("Created new SummaryServer")
+
+# declare flask apps
+app = Flask(__name__)
+#scheduler = APScheduler()
+
+
+# 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 jsonify({
+ "message": "Hello world!",
+ "hostname": get_hostname(),
+ "DriveHealthServer": f"{SummaryServer}"
+ })
+
+
+
+
+# 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
+
+# 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 functions
+
+# 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
+
+def background_loop():
+ return True
+
+def run_main():
+ #if SummaryServer is none:
+
+ #atexit.register(lambda: save_state(SummaryServer)) test
+ # Flask scheduler for background loop, run if requested
+ #scheduler.add_job(id='background_loop',
+ # func=background_loop,
+ # trigger='interval',
+ # seconds=60)
+ #scheduler.init_app(app)
+ #scheduler.start()
+
+ # Flask API
+ background_loop()
+ app.run(debug=False, host='0.0.0.0', port=5001)
+
diff --git a/files/docker/apis/StorageSummary/Storage.py b/files/docker/apis/StorageSummary/Storage.py
new file mode 100644
index 0000000..88d904a
--- /dev/null
+++ b/files/docker/apis/StorageSummary/Storage.py
@@ -0,0 +1,178 @@
+# 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/files/docker/apis/StorageSummary/__pycache__/Helpers.cpython-313.pyc b/files/docker/apis/StorageSummary/__pycache__/Helpers.cpython-313.pyc
new file mode 100644
index 0000000..bfe58fe
Binary files /dev/null and b/files/docker/apis/StorageSummary/__pycache__/Helpers.cpython-313.pyc differ
diff --git a/files/docker/apis/StorageSummary/__pycache__/Routes.cpython-313.pyc b/files/docker/apis/StorageSummary/__pycache__/Routes.cpython-313.pyc
new file mode 100644
index 0000000..36b9b84
Binary files /dev/null and b/files/docker/apis/StorageSummary/__pycache__/Routes.cpython-313.pyc differ
diff --git a/files/docker/apis/StorageSummary/__pycache__/Storage.cpython-313.pyc b/files/docker/apis/StorageSummary/__pycache__/Storage.cpython-313.pyc
new file mode 100644
index 0000000..89e6d45
Binary files /dev/null and b/files/docker/apis/StorageSummary/__pycache__/Storage.cpython-313.pyc differ
diff --git a/files/docker/apis/StorageSummary/app.py b/files/docker/apis/StorageSummary/app.py
new file mode 100644
index 0000000..be1de3c
--- /dev/null
+++ b/files/docker/apis/StorageSummary/app.py
@@ -0,0 +1,13 @@
+# main function for storage API
+
+# import class libraries
+from Routes import *
+
+#######################################################################
+#######################################################################
+### Main Subroutine
+#######################################################################
+#######################################################################
+
+if __name__ == '__main__':
+ run_main()
\ No newline at end of file
diff --git a/files/docker/apis/StorageSummary/requirements.txt b/files/docker/apis/StorageSummary/requirements.txt
new file mode 100644
index 0000000..f8c5953
--- /dev/null
+++ b/files/docker/apis/StorageSummary/requirements.txt
@@ -0,0 +1,7 @@
+flask
+pytz
+requests
+opencv-python
+redis
+flask_apscheduler
+pyyaml
\ No newline at end of file
diff --git a/files/docker/supervisord.conf b/files/docker/supervisord.conf
index 514e8d0..4a58995 100644
--- a/files/docker/supervisord.conf
+++ b/files/docker/supervisord.conf
@@ -44,4 +44,16 @@ directory=/usr/src/app
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
autorestart=true
-priority=4
\ No newline at end of file
+priority=4
+
+
+# ----------------------------------------------------------
+# 5. DriveHealth app
+# ----------------------------------------------------------
+[program:DriveHealth]
+command=/opt/DriveHealth/venv/bin/python3 /opt/DriveHealth/app.py
+directory=/opt/DriveHealth
+stdout_logfile=/dev/stdout
+stderr_logfile=/dev/stderr
+autorestart=true
+priority=5
\ No newline at end of file
diff --git a/files/docker/web/html/index.php b/files/docker/web/html/index.php
index 300f59b..bfe7cf4 100644
--- a/files/docker/web/html/index.php
+++ b/files/docker/web/html/index.php
@@ -1,143 +1,521 @@
+ $s !== ''
+ );
+ } else {
+ $remove_hosts = [];
+ }
+ if (!empty($remove_hosts)){
+ foreach ($remove_hosts as $host) {
+ echo "remove ".$host." ";
+ }
+ removeClient($remove_hosts);
+ }
+ }
+}
+
+# authelia user handler
+
+$authelia_user = "not-set";
+if (isset($_SERVER['HTTP_REMOTE_USER'])) {
+ $authelia_user = $_SERVER['HTTP_REMOTE_USER'];
+}
+
+/* ---------- Helper: remove client details ---------- */
+function removeClient($clientList)
+{
+
+ $url = "http://0.0.0.0:5001/storage_client_delete";
+ $payload = [
+ 'API_KEY' => "deadbeef",
+ 'remove_hosts' => $clientList,
+ ];
+ $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ if ($json === false) {
+ throw new RuntimeException('JSON encoding failed: ' . json_last_error_msg());
+ }
+ $ch = curl_init($url);
+ if ($ch === false) {
+ throw new RuntimeException('Unable to initialise cURL.');
+ }
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $json,
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/json',
+ 'Content-Length: ' . strlen($json),
+ 'Accept: application/json',
+ ],
+ CURLOPT_RETURNTRANSFER=> true,
+ CURLOPT_TIMEOUT => 2,
+ CURLOPT_FOLLOWLOCATION=> true, // follow redirects if any
+ ]);
+ // Execute curl request
+ $response = curl_exec($ch);
+
+
+ // cURL error handling
+ if ($response === false) {
+ $error = curl_error($ch);
+ $errno = curl_errno($ch);
+ curl_close($ch);
+ throw new RuntimeException("cURL error ({$errno}): {$error}");
+ }
+
+ // Grab HTTP status code
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($httpCode < 200 || $httpCode >= 300) {
+ throw new RuntimeException("API returned HTTP {$httpCode}: {$response}");
+ }
+
+ // Decode the JSON response
+ $decoded = json_decode($response, true);
+ if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
+ throw new RuntimeException('Failed to decode JSON response: ' . json_last_error_msg());
+ }
+
+ return $decoded;
+
+}
+
+/**
+ * Escape HTML
+ */
+function h(string $s): string
+{
+ return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
+}
+
+/**
+ * Load simple key/value pairs from a YAML file.
+ * Lines starting with '#' are ignored.
+ * The function returns an associative array.
+ */
+function loadYaml(string $path): array
+{
+ if (!file_exists($path)) {
+ return [];
+ }
+
+ $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+ $data = [];
+
+ foreach ($lines as $line) {
+ $line = trim($line);
+ if ($line === '' || $line[0] === '#') {
+ continue;
+ }
+ $pos = strpos($line, ':');
+ if ($pos === false) {
+ continue;
+ }
+ $key = trim(substr($line, 0, $pos));
+ $value = trim(substr($line, $pos + 1));
+ $value = trim($value, "\"'"); // remove surrounding quotes
+ if ($value === '') {
+ $value = null;
+ }
+ $data[$key] = $value;
+ }
+
+ return $data;
+}
+
+/* ---------- Load settings ---------- */
+
+$settingsPath = '/app/cosmostat_settings.yaml';
+$settings = loadYaml($settingsPath);
+
+
+/* ---------- Page mode handling ---------- */
+
+$mode = $_GET['mode'] ?? 'cosmostat'; // default mode
+$validModes = ['cosmostat', 'drive_health']; // extend as needed
+// 'gali',
+if (!in_array($mode, $validModes, true)) {
+ $mode = 'cosmostat';
+}
+
+
+/* ---------- API configuration per mode ---------- */
+$apiConfig = [
+ 'cosmostat' => ['bind' => '10.200.27.20', 'port' => '5000'],
+ /*'gali' => ['bind' => '10.200.27.20', 'port' => '5000'], // same as cosmostat*/
+ 'drive_health' => ['bind' => '0.0.0.0', 'port' => '5001'], // new API
+];
+
+/* ---------- Helper: fetch client details ---------- */
+function fetchClientDetails(string $bindIp, string $port, string $path = '/client_details'): array
+{
+ $url = "http://{$bindIp}:{$port}{$path}";
+ $ctx = stream_context_create([
+ 'http' => [
+ 'timeout' => 2,
+ 'header' => "User-Agent: PHP/" . PHP_VERSION . "\r\n"
+ ]
+ ]);
+ $json = @file_get_contents($url, false, $ctx);
+ if ($json === false) {
+ return []; // caller will handle empty case
+ }
+ $data = json_decode($json, true);
+ if (!is_array($data)) {
+ return ['fail'];
+ }
+ return $data;
+}
+
+/* ---------- Fetch client details ---------- */
+$apiInfo = $apiConfig[$mode];
+$clients = fetchClientDetails($apiInfo['bind'], $apiInfo['port']);
+
+/* ---------- Ensure each client has a short_id ---------- */
+foreach ($clients as &$client) {
+ if (!isset($client['short_id'])) {
+ $client['short_id'] = substr($client['uuid'] ?? '', 0, 8);
+ }
+}
+unset($client);
+
+
+/* ---------- Determine selected hosts (Drive Health only) ---------- */
+$selectedHosts = $_GET['hosts'] ?? [];
+
+if ($mode === 'drive_health') {
+ if (isset($_GET['action'])) {
+ switch ($_GET['action']) {
+ case 'all':
+ $selectedHosts = array_column($clients, 'short_id');
+ break;
+ case 'none':
+ $selectedHosts = [];
+ break;
+ // 'apply' – nothing to do; $selectedHosts already contains the posted hosts
+ }
+ }
+}
+if ($mode === 'drive_health' && empty($selectedHosts) && !isset($_GET['action'])) {
+ $selectedHosts = array_column($clients, 'short_id');
+}
+/* ---- Determine selected host ---- */
+
+$selectedId = $_GET['host'] ?? '';
+$selectedIdx = null;
+
+foreach ($clients as $idx => $client) {
+ if (isset($client['short_id']) && $client['short_id'] === $selectedId) {
+ $selectedIdx = $idx;
+ break;
+ }
+}
+if ($selectedIdx === null) {
+ // Default to the first client (if any)
+ $selectedIdx = 0;
+ $selectedId = $clients[$selectedIdx]['short_id'] ?? '';
+}
+#global $clients, $client, $properties, $systemProperties, $systemComponents, $selectedHost, $selectedHosts, $selectedId, $selectedIdx;
+
+$client = $clients[$selectedIdx] ?? null;
+$properties = $client['client_properties'][0] ?? [];
+$systemProperties = $properties['system_properties'] ?? [];
+$systemComponents = $properties['system_components'] ?? [];
+$selectedHost = $clients[$selectedIdx]['hostname'] ?? 'Unknown';
+
+
+
+/* ---- ---- */
+
+/* ---- Sidebar Renderer ---- */
+
+function renderSidebar(string $mode){
+ global $clients, $client, $properties, $systemProperties, $systemComponents, $selectedHost, $selectedHosts, $selectedId, $selectedIdx, $remove_hosts;
+
+ $modes = [
+ 'cosmostat' => 'Cosmostat',
+ /* 'gali' => 'Shuttle Gali',*/
+ 'drive_health' => 'Drive Health',
+ ];
+
+ ?>
+
+
+
Could not retrieve data from the API for mode "' . h($mode) . '".
');
+ }
+
+ if ($mode === 'drive_health') {
+ // If nothing is selected, show a friendly message
+ if (empty($selectedHosts)) {
+ echo '
No hosts selected.
';
+ return;
+ }
+ echo '
';
+ foreach ($selectedHosts as $sid) {
+ // Find the client that matches this short_id
+ $c = null;
+ foreach ($clients as $cl) {
+ if ($cl['short_id'] === $sid) {
+ $c = $cl;
+ break;
+ }
+ }
+ if ($c === null) continue; // safety
+
+ $hostname = $c['name'] ?? 'Unknown';
+ echo '
This dashboard shows the local Matt-Cloud system stats.
+
API
+
+
+
+ Component Desriptor
+
To view the component descriptor, you may
+ curl -s https://= h($_SERVER['SERVER_NAME']) ?>/descriptor
+ This will return the entire JSON descriptor variable.
+ The endpoint agent uses this descriptor to build out its local System Object.
+ The agent then reports back to the Cosmostat Server with all the data found in the descriptor.
+ Full Source Code can be found at its Gitea page.
+
This dashboard shows the local Matt-Cloud system stats.
+
API
+
+
+
+ Component Desriptor
+
To view the component descriptor, you may
+ curl -s https://= h($_SERVER['SERVER_NAME']) ?>/descriptor
+ This will return the entire JSON descriptor variable.
+ The agent then reports back to the Cosmostat Server with all the data found in the descriptor.
+ Full Source Code can be found at its Gitea page.
+
This dashboard shows the local Matt-Cloud system stats.
+
API
+
+
+
+ Component Desriptor
+
To view the component descriptor, you may
+ curl -s https://= h($_SERVER['SERVER_NAME']) ?>/descriptor
+ This will return the entire JSON descriptor variable.
+ The endpoint agent uses this descriptor to build out its local System Object.
+ The agent then reports back to the Cosmostat Server with all the data found in the descriptor.
+ Full Source Code can be found at its Gitea page.
+
This dashboard shows the local Matt-Cloud system stats.
+
API
+
+
+
+
+ Component Desriptor
+
To view the component descriptor, you may
+ curl -s https://= h($_SERVER['SERVER_NAME']) ?>/descriptor
+ This will return the entire JSON descriptor variable.
+ The endpoint agent uses this descriptor to build out its local System Object.
+ The agent then reports back to the Cosmostat Server with all the data found in the descriptor.
+ Full Source Code can be found at its Gitea page.
+