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('

Could not fetch data from the API.

'); +} +$data = json_decode($json, true); +if ($data === null) { + die('

Malformed JSON returned from the API.

'); +} +function h(string $s): string +{ + return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); +} +?> + + + + + + + + Cosmostat - <?php echo $_SERVER['SERVER_NAME'] ?> + + + + +
+ +
+ + +
+

Matt-Cloud Cosmostat Dashboard

+ This dashboard shows the local Matt-Cloud system stats.

+

+
+ + +
+ Component Desriptor +

To view the component descriptor, you may
+ curl -s https:///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

+ + +
+
    +
  • +
+
+

Live System Metrics

+ +
+ Connecting... +
+
+
+ + +
+ + +
+

Components

+ +
+ + + +
+

+
    +
  • +
+
+
+
+
+
+ + + + + + + + + + + + \ 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'];
+

Components

diff --git a/files/server/system_metrics.js b/files/archive/server/system_metrics.js similarity index 100% rename from files/server/system_metrics.js rename to files/archive/server/system_metrics.js diff --git a/files/archive/web/pre-multi-mode/index.php b/files/archive/web/pre-multi-mode/index.php new file mode 100644 index 0000000..493abed --- /dev/null +++ b/files/archive/web/pre-multi-mode/index.php @@ -0,0 +1,321 @@ + [ + 'timeout' => 5, + 'header' => "User-Agent: PHP/" . PHP_VERSION . "\r\n" + ] + ]); + $json = @file_get_contents($apiUrl, false, $context); + + if ($json === false) { + die('

Could not fetch data from the API.

'); + } + + $data = json_decode($json, true); + if ($data === null) { + die('

Malformed JSON returned from the API.

'); + } + + /* ---- Render client dashboard ---- */ + ?> + + + + + Cosmostat - <?= h($_SERVER['SERVER_NAME']) ?> + + + +
+ +
+ +
+

Matt-Cloud Cosmostat Dashboard

+ This dashboard shows the local Matt-Cloud system stats.

+

+
+ +
+ Component Desriptor +

To view the component descriptor, you may
+ curl -s https:///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

+ + + +
+
    + +
  • + +
+
+

Live System Metrics

+
Connecting...
+
+
+ +
+ +
+ +

Components

+
+ +
+

+
    + +
  • + +
+
+ +
+ +
+
+
+ + + + + + [ + 'timeout' => 5, + 'header' => "User-Agent: PHP/" . PHP_VERSION . "\r\n" + ] +]); +$json = @file_get_contents($apiUrl, false, $context); +if ($json === false) { + die('

Could not fetch data from the API.

'); +} + +$clients = json_decode($json, true); +if ($clients === null || !is_array($clients)) { + die('

Malformed JSON returned from the API.

'); +} + +/* ---- 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'] ?? ''; +} + +$client = $clients[$selectedIdx] ?? null; +$properties = $client['client_properties'][0] ?? []; +$systemProperties = $properties['system_properties'] ?? []; +$systemComponents = $properties['system_components'] ?? []; +$selectedHost = $clients[$selectedIdx]['hostname'] ?? 'Unknown'; + +/* ---- Render server dashboard ---- */ +?> + + + + +Cosmostat - <?= h($selectedHost) ?> + + + +
+ + + +
+ +
+

Matt-Cloud Cosmostat Dashboard

+

This dashboard shows the local Matt-Cloud system stats.

+ +
+ +
+ Component Desriptor +

To view the component descriptor, you may
+ curl -s https:///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

+ + + +
+
    + +
  • + +
+
+

Live System Metrics

+
Connecting...
+
+
+ +
+ + +
+ +

Components

+
+ +
+

+
    + +
  • + +
+
+ +
+ +
+
+
+ + + + + \ 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 '
+ '; + echo '

Drive Health - ' . h($hostname) . '

+

IP: '.h($c['ip']).'
+ Timestamp: '.date('F j, Y g:i a', (int) $c['timestamp']).'

+ '; + + if (isset($c['drives']) && is_array($c['drives']) && count($c['drives']) > 0) { + echo ''; + echo ' + '; + echo ' + '; + foreach ($c['drives'] as $drive) { + echo ' + '; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ' + '; + } + echo '
Drive LetterDisk IDHealth StatusModelCapacityPower On HoursHost WritesWear Level
' . h($drive['drive_letter'] ?? '') . '' . h("".$drive['disk_id'] ?? '') . '' . h($drive['health_status'] ?? '') . '' . h($drive['model'] ?? '') . '' . h($drive['capacity'] ?? '') . '' . h($drive['power_on_hours'] ?? '') . '' . h($drive['host_writes'] ?? '') . '' . h($drive['wear_level'] ?? '') . '
+ '; + } else { + echo '

No drive data available for this host.

'; + } + echo '
'; + } + echo '
'; + return; + } + ?> +
+ + +
+

Matt-Cloud Cosmostat Dashboard

+

This dashboard shows the local Matt-Cloud system stats.

+ +
+ +
+ Component Desriptor +

To view the component descriptor, you may
+ curl -s https:///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

+ + + +
+
    + +
  • + +
+
+

Live System Metrics

+
Connecting...
+
+
+ +
+ + +
+ +

Components

+
+ +
+

+
    + +
  • + +
+
+ +
+ +
+ + + + + +

Shuttle Gali

+ + + +
+ - - Cosmostat - <?php echo $_SERVER['SERVER_NAME'] ?> - - - - + +Cosmostat - <?= h($selectedHost) ?> + -
-

Matt-Cloud Cosmostat Dashboard

- This dashboard shows the local Matt-Cloud system stats.

-

-
-
- Component Desriptor

- To view the component descriptor, you may
- - curl -s https:///descriptor
-
- This will return the entire JSON descriptor variable -

- -
-
+
+ + + + - - "; - $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('

Could not fetch data from the API.

'); - } - $data = json_decode($json, true); - if ($data === null) { - die('

Malformed JSON returned from the API.

'); - } - function h(string $s): string - { - return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); - } - ?> +
- -

System Properties

-
- - -
-
    - -
  • - -
-
- -

Live System Metrics

-
Connecting...
-
-
- - - -

Components

-
- -
-

-
    - -
  • - -
-
- -
- - - - -
-
- - - - - - - - - + + + + + + + + \ No newline at end of file diff --git a/files/docker/web/html/src/redis.js b/files/docker/web/html/src/redis.js index dcfcdf0..39d7b0f 100644 --- a/files/docker/web/html/src/redis.js +++ b/files/docker/web/html/src/redis.js @@ -1,5 +1,5 @@ /* ------------------------------------------------------------ - 1. Socket-IO connection & helper functions (unchanged) + 1. Socket-IO connection & helper functions ------------------------------------------------------------ */ const socket = io(); diff --git a/files/docker/web/html/src/styles.css b/files/docker/web/html/src/styles.css index 2084b8c..0140e04 100644 --- a/files/docker/web/html/src/styles.css +++ b/files/docker/web/html/src/styles.css @@ -32,7 +32,7 @@ a:hover { text-decoration: underline; } /* ------------------------------------------------- 2. Layout - wrapper, sidebar, main ------------------------------------------------- */ -.wrapper { display: flex; min-height: 100vh; } +.wrapper { display: flex; } .sidebar { position: fixed; /* keep sidebar visible during scroll */ @@ -244,3 +244,17 @@ li { margin-bottom: 10px; color: var(--clr-text); } font-weight: bold; } + +/* for stacking storage clients */ +.storage_client{ + + flex: 1; + padding: 1rem; + padding-left: 200px; /* space for the fixed sidebar */ + overflow-x: hidden; + display: flex; /* make it a flex container */ + flex-direction: column; /* stack its children (cards) vertically */ + align-items: center; /* center the cards horizontally */ + +} + diff --git a/files/docker/web/html/src/system_metrics.js b/files/docker/web/html/src/system_metrics.js new file mode 100644 index 0000000..e72847e --- /dev/null +++ b/files/docker/web/html/src/system_metrics.js @@ -0,0 +1,317 @@ +/* ============================================================== + system_metrics.js + ============================================================== */ +(() => { + /* ========================================================== + Socket.IO setup + ========================================================== */ + const socket = io({ + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 3000, + reconnectionDelayMax: 60000, + timeout: 60000, + pingTimeout: 5000, + pingInterval: 25000, + }); + /* ========================================================== + Color constants + ========================================================== */ + const GREEN = [ 39, 174, 96]; // #27ae60 + const YELLOW = [243, 156, 18]; // #f39c12 + const RED = [192, 57, 43]; // #c0392b + /* ========================================================== + Helpers + ========================================================== */ + const hostTimestamps = {}; // keyed by short_id + const toRgb = (r, g, b) => `rgb(${r},${g},${b})`; + const T20 = 20 * 1000; + const T40 = 40 * 1000; + const T60 = 60 * 1000; + function getFreshnessColor(ageMs) { + if (ageMs <= T20) { + return toRgb(...GREEN); + } + if (ageMs <= T40) { + const t = (ageMs - T20) / (T40 - T20); + const r = Math.round(GREEN[0] + t * (YELLOW[0] - GREEN[0])); + const g = Math.round(GREEN[1] + t * (YELLOW[1] - GREEN[1])); + const b = Math.round(GREEN[2] + t * (YELLOW[2] - GREEN[2])); + return toRgb(r, g, b); + } + if (ageMs <= T60) { + const t = (ageMs - T40) / (T60 - T40); + const r = Math.round(YELLOW[0] + t * (RED[0] - YELLOW[0])); + const g = Math.round(YELLOW[1] + t * (RED[1] - YELLOW[1])); + const b = Math.round(YELLOW[2] + t * (RED[2] - YELLOW[2])); + return toRgb(r, g, b); + } + return toRgb(...RED); + } + function safeSetText(id, txt) { + const el = document.getElementById(id); + if (el) el.textContent = txt; + } + /* ========================================================== + Get the short_id from the query string + ========================================================== */ + function getSelectedId() { + return new URLSearchParams(window.location.search).get('host') || ''; + } + /* ========================================================== + Sidebar building - uses short_id for status key + ========================================================== */ + + function buildList(systemList) { + const ul = document.getElementById('endpointList'); + + if (!Array.isArray(systemList)) { + ul.innerHTML = ''; // nothing to show + return; + } + + /* ──────────────────────────────────────── + * Sort: servers first, then by IP + * ──────────────────────────────────────── */ + const toInt = ip => { + // guard against undefined / empty string + if (!ip) return 0; + return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0); + }; + + const sorted = [...systemList].sort((a, b) => { + // a. Servers go before non‑servers + const aServer = !!a.is_server; + const bServer = !!b.is_server; + if (aServer !== bServer) return aServer ? -1 : 1; // true < false + + // b. Same “is_server” status – fall back to IP sorting + const aIp = a.active_ip ?? ''; + const bIp = b.active_ip ?? ''; + + if (!aIp) return 1; // push empty IPs to the end + if (!bIp) return -1; + + return toInt(aIp) - toInt(bIp); + }); + + /* ──────────────────────────────────────── + * Bail if nothing actually changed + * ──────────────────────────────────────── */ + const current = Array.from(ul.children).map(li => li.dataset.id); + const newIds = sorted.map(s => s.short_id); + if (arraysEqual(current, newIds)) return; // no visual change needed + + /* ──────────────────────────────────────── + * Build the DOM + * ──────────────────────────────────────── */ + const selected = getSelectedId().toLowerCase(); + ul.innerHTML = ''; // reset + + sorted.forEach(item => { + const li = document.createElement('li'); + + // • Status dot + const status = document.createElement('span'); + status.className = 'host-status'; + status.dataset.id = item.short_id; + + // • Link – display hostname, encode short_id in URL + const a = document.createElement('a'); + a.href = '?host=' + encodeURIComponent(item.short_id); + a.textContent = item.hostname; + a.title = item.active_ip ? `Active IP: ${item.active_ip}` : ''; + if (item.short_id.toLowerCase() === selected) a.classList.add('active'); + + li.appendChild(status); + li.appendChild(a); + ul.appendChild(li); + }); + } + + /* ========================================================== + Update status colors every second + ========================================================== */ + function updateStatusColors() { + const nowSec = Date.now() / 1000; + Object.entries(hostTimestamps).forEach(([id, ts]) => { + const ageMs = (nowSec - ts) * 1000; + const color = getFreshnessColor(ageMs); + const span = document.querySelector( + `.host-status[data-id="${id}"]` + ); + if (span) span.style.backgroundColor = color; + }); + } + setInterval(updateStatusColors, 1000); + /* ========================================================== + Utility helpers + ========================================================== */ + function arraysEqual(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; + } + function renderGenericTable(containerId, data, emptyMsg) { + const container = document.getElementById(containerId); + if (!Array.isArray(data) || !data.length) { + container.textContent = emptyMsg; + return; + } + const merged = mergeRowsByName(data); + const ordered = orderRows(merged); + const table = buildTable(ordered); + table.id = 'host_metrics_table'; + container.innerHTML = ''; + container.appendChild(table); + } + function mergeRowsByName(rows) { + const groups = {}; // { Source: { Metric: [], Data: [] } } + rows.forEach(r => { + const src = r.Source; + if (!src) return; + if (!groups[src]) groups[src] = { Metric: [], Data: [] }; + if ('Metric' in r && 'Data' in r) { + groups[src].Metric.push(r.Metric); + groups[src].Data.push(r.Data); + } + }); + return Object.entries(groups).map(([src, g]) => ({ + Source: src, + Metric: g.Metric, + Data: g.Data, + })); + } + function orderRows(rows) { + const priority = ['System', 'CPU', 'RAM']; + const map = {}; + priority.forEach((s, i) => map[s] = i); + return [...rows].sort((a, b) => { + const ai = map.hasOwnProperty(a.Source) ? map[a.Source] : Infinity; + const bi = map.hasOwnProperty(b.Source) ? map[b.Source] : Infinity; + return ai - bi; + }); + } + function buildTable(rows) { + const cols = ['Source', 'Metric', 'Data']; + const table = document.createElement('table'); + // Header + const thead = table.createTHead(); + const headerRow = thead.insertRow(); + cols.forEach(col => { + const th = document.createElement('th'); + th.textContent = col; + headerRow.appendChild(th); + }); + // Body + const tbody = table.createTBody(); + rows.forEach(item => { + const tr = tbody.insertRow(); + cols.forEach(col => { + const td = tr.insertCell(); + const val = item[col]; + if (Array.isArray(val)) { + val.forEach((v, i) => { + const span = document.createElement('span'); + span.textContent = v; + td.appendChild(span); + if (i < val.length - 1) td.appendChild(document.createElement('br')); + }); + } else { + td.textContent = val !== undefined ? val : ''; + } + }); + }); + return table; + } + /* ========================================================== + Handle incoming data + ========================================================== */ + let lastUpdate = Date.now(); + function handleSummary(raw) { + lastUpdate = Date.now(); // reset watchdog + let payload; + if (typeof raw === 'string') { + try { payload = JSON.parse(raw); } catch (e) { + safeSetText('client_summary', 'Invalid data received'); + return; + } + } else payload = raw; + if (!Array.isArray(payload) || !payload.length) { + safeSetText('client_summary', 'No data available'); + return; + } + // Build the list first (so elements exist) + buildList(payload); + // Store the timestamp for every short_id + payload.forEach(hostObj => { + if (hostObj.short_id && hostObj.data_timestamp) { + hostTimestamps[hostObj.short_id] = hostObj.data_timestamp; // seconds + } + }); + // Immediately update colors for the current view + updateStatusColors(); + // Metric table for selected host + const selectedId = getSelectedId(); + const hostObj = payload.find(h => h.short_id === selectedId) || payload[0]; + const hostData = hostObj && Array.isArray(hostObj.redis_data) + ? hostObj.redis_data + : []; + renderGenericTable('host_metrics', hostData, 'No Stats available'); + } + /* ========================================================== + Socket event wiring + ========================================================== */ + socket.on('client_summary', handleSummary); + socket.on('connect', () => { + safeSetText('client_summary', 'Connected'); + requestSummary(); + }); + socket.on('disconnect', () => { + safeSetText('client_summary', 'Disconnected - retrying...'); + }); + socket.on('reconnect', attempt => { + safeSetText('client_summary', `Re-connected (attempt ${attempt})`); + requestSummary(); + }); + /* ========================================================== + Request logic + ========================================================== */ + function requestSummary() { + if (!socket.connected) return; // guard against stale emits + socket.emit('get_client_summary'); // server will reply via client_summary + } + /* ========================================================== + Recursive polling + ========================================================== */ + let pollTimer = null; + function pollLoop() { + if (!socket.connected) return; + requestSummary(); + pollTimer = setTimeout(pollLoop, 5000); + } + socket.on('connect', () => { + if (!pollTimer) pollLoop(); + }); + /* ========================================================== + Watchdog - force reconnect if no data for 15 s + ========================================================== */ + function watchdog() { + if (Date.now() - lastUpdate > 15000 && socket.connected) { + safeSetText('client_summary', 'No updates - reconnecting...'); + socket.disconnect(); // forces a reconnect cycle + } + setTimeout(watchdog, 5000); + } + watchdog(); + /* ========================================================== + Keep the 'active' link in sync when the URL changes + ========================================================== */ + window.addEventListener('popstate', () => { + const selected = getSelectedId().toLowerCase(); + document.querySelectorAll('#endpointList a').forEach(a => + a.classList.toggle('active', a.href.includes('host=' + encodeURIComponent(selected))) + ); + }); +})(); \ No newline at end of file diff --git a/files/docker/web/html/test.php b/files/docker/web/html/test.php new file mode 100644 index 0000000..7f69431 --- /dev/null +++ b/files/docker/web/html/test.php @@ -0,0 +1,477 @@ + $bindIp, + 'port' => $port, + 'path' => $path, + 'API_KEY' => API_KEY, + 'remove_hosts' => $clientList, + ]; + $jsonPayload = json_encode($payload); + if ($jsonPayload === false) { + // Encoding failed – return a clear error. + return [ + 'status' => 'error', + 'code' => 0, + 'message' => 'Failed to encode request payload: ' . json_last_error_msg(), + ]; + } + + // Prepare the HTTP context for a POST request. + $headers = [ + "Content-Type: application/json", + "Content-Length: " . strlen($jsonPayload), + "User-Agent: PHP/" . PHP_VERSION, + ]; + + $ctxOptions = [ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => $jsonPayload, + 'timeout' => 5, + 'ignore_errors' => true, // Still receive body for 4xx/5xx + ] + ]; + + $ctx = stream_context_create($ctxOptions); + + + $url = "http://172.25.1.18:5001/storage_client_delete"; + $ctx = stream_context_create([ + 'http' => [ + 'timeout' => 5, + '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; +} + +$removedHostsMsg = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $raw = file_get_contents('php://input'); + $data = json_decode($raw, true); + if (json_last_error() === JSON_ERROR_NONE + && isset($data['API_KEY'], $data['remove_hosts']) + && $data['API_KEY'] === API_KEY + ) { + /* ---- For demo purposes we just echo a message. In a real + system you would perform the removal logic here. ---- */ + $removedHostsMsg = '

Removed hosts: ' + . h(implode(', ', $data['remove_hosts'])) + . '

'; + echo "Time to remove ".$data['remove_hosts']." ['bind' => '10.200.27.20', 'port' => '5000'], + 'drive_health' => ['bind' => '172.25.1.18', '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' => 5, + '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 + } + } +} +/* ---- 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'] ?? ''; +} +$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, $removedHostsMsg; + /* Show a removal‑success message if we just processed a POST */ + if ($removedHostsMsg) { + echo $removedHostsMsg; + } + $modes = [ + 'cosmostat' => 'Cosmostat', + '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 '
'; + echo '

Drive Health - ' . h($hostname) . '

'; + echo '

IP: ' . h($c['ip']) . '
'; + echo 'Timestamp: ' . date('F j, Y g:i a', (int) $c['timestamp']) . '

'; + if (isset($c['drives']) && is_array($c['drives']) && count($c['drives']) > 0) { + echo ''; + echo ''; + echo ''; + foreach ($c['drives'] as $drive) { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
Drive LetterModelCapacityPower On HoursHost WritesWear Level
' . h($drive['drive_letter'] ?? '') . '' . h($drive['model'] ?? '') . '' . h($drive['capacity'] ?? '') . '' . h($drive['power_on_hours'] ?? '') . '' . h($drive['host_writes'] ?? '') . '' . h($drive['wear_level'] ?? '') . '
'; + } else { + echo '

No drive data available for this host.

'; + } + echo '
'; + } + echo '
'; + return; + } + ?> +
+ +
+

Matt-Cloud Cosmostat Dashboard

+

This dashboard shows the local Matt-Cloud system stats.

+ +
+ +
+ Component Desriptor +

To view the component descriptor, you may
+ curl -s https:///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. +
+ +
+ +

System Properties

+ + + +
+
    + +
  • + +
+
+

Live System Metrics

+
Connecting...
+
+
+ +
+ + +
+ +

Components

+
+ +
+

+
    + +
  • + +
+
+ +
+ +
+ + + +

Shuttle Gali

+ + +
+ + + + + +Cosmostat - <?= h($selectedHost) ?> + + + +
+ + + + + +
+ + + + + \ No newline at end of file diff --git a/files/docker/web/node_server/server.js b/files/docker/web/node_server/server.js index 721841b..214ac8f 100644 --- a/files/docker/web/node_server/server.js +++ b/files/docker/web/node_server/server.js @@ -24,19 +24,26 @@ try { console.error('Failed to load config.yaml:', e); process.exit(1); } - const API_PORT = config.custom_api_port || 5000; // fallback to 5000 const API_HOST = config.api_bind_ip || '192.168.37.1'; // fallback IP const API_BASE = `http://${API_HOST}:${API_PORT}`; console.log('API URL:', API_BASE); -// --------------------------------------------------------------------- -// ---------- 2. Socket.io ------------------------------------------------ -// --------------------------------------------------------------------- +/* --------------------------------------------------------------------- */ +/* ---------- 2. Socket.io -------------------------------------------- */ +/* --------------------------------------------------------------------- */ io.on('connection', async socket => { console.log('client connected:', socket.id); - // Call the external API every time a client connects + /* ------------- send cached client_summary ------------- */ + if (clientSummaryCache.last) { + socket.emit('client_summary', clientSummaryCache.last); + console.log('sent cached client_summary to', socket.id); + } + + /* ----------------------------------------------------------------- */ + /* Call the external API every time a client connects */ + /* ----------------------------------------------------------------- */ try { const resp = await fetch(`${API_BASE}/start_timer`, { method: 'GET' }); const data = await resp.json(); @@ -45,7 +52,9 @@ io.on('connection', async socket => { console.error('Failed to hit start_timer endpoint:', err); } - // Listen for tableRendered event from the client + /* ----------------------------------------------------------------- */ + /* Listen for tableRendered event from the client */ + /* ----------------------------------------------------------------- */ socket.on('tableRendered', async () => { console.log('Client reported table rendered - starting timer'); try { @@ -62,24 +71,29 @@ io.on('connection', async socket => { /* ---------- 3. Serve static files ----------------------------------- */ /* --------------------------------------------------------------------- */ app.use(express.static('public')); -/* --- 4. Redis subscriber (patched) --------------------------------- */ + +/* --------------------------------------------------------------------- */ +/* ---------- 4. Redis subscriber ------------------------------------- */ +/* --------------------------------------------------------------------- */ const redisClient = createClient({ url: 'redis://0.0.0.0:6379', socket: { keepAlive: 60000, // 60 s TCP keep-alive reconnectStrategy: attempts => Math.min(attempts * 100, 3000) } // back-off }); - redisClient.on('error', err => console.error('Redis error', err)); +/* --- local cache for client_summary -------------------------------- */ +const clientSummaryCache = {}; // { last: } + +/* --------------------------------------------------------------------- */ (async () => { await redisClient.connect(); - const sub = redisClient.duplicate(); await sub.connect(); - // -------------------------------------------------------------------- - // Helper that re-subscribes to a channel (and re-sends the handler) - // -------------------------------------------------------------------- + /* --------------------------------------------------------------------- */ + /* Helper that re-subscribes to a channel (and re-sends the handler) */ + /* --------------------------------------------------------------------- */ async function safeSubscribe(channel, handler) { try { await sub.subscribe(channel, handler); @@ -89,27 +103,33 @@ redisClient.on('error', err => console.error('Redis error', err)); } } - // --------------------------------------------------------------- - // Subscribe to all required channels - // --------------------------------------------------------------- + /* --------------------------------------------------------------------- */ + /* Subscribe to all required channels */ + /* --------------------------------------------------------------------- */ await safeSubscribe('host_metrics', (msg) => forward('host_metrics', msg)); await safeSubscribe('client_summary', (msg) => forward('client_summary', msg)); - // --------------------------------------------------------------- - // Forward messages to Socket.io - // --------------------------------------------------------------- + /* --------------------------------------------------------------------- */ + /* Forward messages to Socket.io */ + /* --------------------------------------------------------------------- */ function forward(channel, message) { try { const payload = JSON.parse(message); + + /* ----- update cache on client_summary ----- */ + if (channel === 'client_summary') { + clientSummaryCache.last = payload; + } + io.emit(channel, payload); } catch (e) { console.error(`Failed to parse message from ${channel}`, e); } } - // ---------------------------------------------------------------- - // Re-subscribe automatically when the Redis connection reconnects - // ---------------------------------------------------------------- + /* --------------------------------------------------------------------- */ + /* Re-subscribe automatically when the Redis connection reconnects */ + /* --------------------------------------------------------------------- */ sub.on('reconnecting', () => console.log('Redis reconnecting…')); sub.on('ready', async () => { console.log('Redis ready - re-subscribing to all channels'); @@ -117,7 +137,7 @@ redisClient.on('error', err => console.error('Redis error', err)); await safeSubscribe('client_summary', (msg) => forward('client_summary', msg)); }); - // Optional: if the connection ends for any reason, close the process + /* Optional: if the connection ends for any reason, close the process */ sub.on('end', () => { console.error('Redis connection closed - exiting'); process.exit(1); diff --git a/files/docker/web/proxy/nginx.conf b/files/docker/web/proxy/nginx.conf index c442dfa..8f4a968 100644 --- a/files/docker/web/proxy/nginx.conf +++ b/files/docker/web/proxy/nginx.conf @@ -34,6 +34,21 @@ server_name localhost; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + location = /storage_client_update { + proxy_pass http://0.0.0.0:5001/storage_client_update; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location = /test_storage_summary { + proxy_pass http://0.0.0.0:5001/test_storage_summary; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # --------------------------------------- # WebSocket endpoint diff --git a/files/scripts/check_cosmostat.sh b/files/scripts/check_cosmostat.sh new file mode 100644 index 0000000..ea9446d --- /dev/null +++ b/files/scripts/check_cosmostat.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +# ------------------------------------------------------------------ +# Grab the list of IPs +# ------------------------------------------------------------------ +readarray -t ips < <(curl -s http://192.168.37.1:5000/client_inventory | grep ansible_host | cut -d: -f2 | awk '{print $1}') + +# ------------------------------------------------------------------ +# Inspect the result (optional, handy for debugging) +# ------------------------------------------------------------------ +echo "Found ${#ips[@]} IP(s):" +for ip in "${ips[@]}"; do + echo " $ip" +done + +# ------------------------------------------------------------------ +# Use the array – for example, SSH into each host +# ------------------------------------------------------------------ +USER="root" +KEY="~/.ssh/id_rsa" +PORT=22 + +for host in "${ips[@]}"; do + echo "=== SSHing to $host ===" + ssh -o BatchMode=yes -p "$PORT" -i "$KEY" "$USER@$host" "hostname && docker ps --format=json | jq -r '.Names' | grep cosmostat" + echo +done \ No newline at end of file diff --git a/files/vizz/docker/Dockerfile b/files/vizz/docker/Dockerfile new file mode 100644 index 0000000..8f9fb09 --- /dev/null +++ b/files/vizz/docker/Dockerfile @@ -0,0 +1,51 @@ + +# Base image +FROM php:8.1-apache + +# Install system packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + # Nginx + #redis-server + nginx \ + # Process supervisor + supervisor \ + # Others + net-tools \ + # Clean up + && rm -rf /var/lib/apt/lists/* + +# Install Node.js LTS 18.x +RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ + && apt-get update && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# copy the config file +COPY cosmostat_settings.yaml /app/cosmostat_settings.yaml + +# Node on 3000 +WORKDIR /usr/src/app +COPY web/node_server/ . +#COPY cosmostat_settings.yaml /usr/src/app/cosmostat_settings.yaml +RUN npm install --only=production + +# Apache on 8080 +COPY apache_ports.conf /etc/apache2/ports.conf +COPY apache_vhost.conf /etc/apache2/sites-available/000-default.conf +COPY web/html/ /var/www/html/ + +# nginx on 80 +RUN rm -rf /etc/nginx/sites-enabled/default +COPY web/proxy/nginx.conf /etc/nginx/conf.d/default.conf + +# Add supervisord configuration +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf + +# Expose ports +EXPOSE 80 +#EXPOSE 6379 + +# healthcheck looks for apache +HEALTHCHECK CMD netstat -ltn | grep -c ":8080" > /dev/null; if [ 0 != $? ]; then exit 1; fi; + +# Start supervisord +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] \ No newline at end of file diff --git a/files/vizz/docker/apache_ports.conf b/files/vizz/docker/apache_ports.conf new file mode 100644 index 0000000..b1422ea --- /dev/null +++ b/files/vizz/docker/apache_ports.conf @@ -0,0 +1,5 @@ +# Listen on 8080 inside the container +Listen 8080 + +# If you still want Apache to listen on 80 (rare), add it back: +# Listen 80 \ No newline at end of file diff --git a/files/vizz/docker/apache_vhost.conf b/files/vizz/docker/apache_vhost.conf new file mode 100644 index 0000000..07a9be5 --- /dev/null +++ b/files/vizz/docker/apache_vhost.conf @@ -0,0 +1,14 @@ + + ServerAdmin webmaster@localhost + DocumentRoot /var/www/html + + # Log files + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # If you need PHP processing + + AllowOverride All + Require all granted + + \ No newline at end of file diff --git a/files/vizz/docker/supervisord.conf b/files/vizz/docker/supervisord.conf new file mode 100644 index 0000000..5c57cd8 --- /dev/null +++ b/files/vizz/docker/supervisord.conf @@ -0,0 +1,47 @@ +[supervisord] +nodaemon=true +logfile=/dev/stdout +logfile_maxbytes=0 + +# ------------------------------------------------------------------ +# 1. Apache (from the base image) +# ------------------------------------------------------------------ +[program:apache2] +command=/usr/sbin/apache2ctl -D FOREGROUND +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +autorestart=true +priority=1 + +# ------------------------------------------------------------------ +# 2. Redis +# ------------------------------------------------------------------ +#[program:redis] +#command=/usr/bin/redis-server --daemonize no --protected-mode no +#stdout_logfile=/dev/stdout +#stderr_logfile=/dev/stderr +#autorestart=true +#priority=2 + +# ------------------------------------------------------------------ +# 3. Nginx (will listen on 8080 by default) +# ------------------------------------------------------------------ +[program:nginx] +command=/usr/sbin/nginx -g 'daemon off;' +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +autorestart=true +priority=3 + +# ------------------------------------------------------------------ +# 4. Node.js +# ------------------------------------------------------------------ +# NOTE: Adjust the command/path to match your app +[program:node] +command=sh -c "npm install && node server.js" +#command=npm start +directory=/usr/src/app +stdout_logfile=/dev/stdout +stderr_logfile=/dev/stderr +autorestart=true +priority=4 \ No newline at end of file diff --git a/files/vizz/docker/web/html/index.php b/files/vizz/docker/web/html/index.php new file mode 100644 index 0000000..554f8f5 --- /dev/null +++ b/files/vizz/docker/web/html/index.php @@ -0,0 +1,384 @@ + ['bind' => '10.200.27.20', 'port' => '5000'], + 'gali' => ['bind' => '10.200.27.20', 'port' => '5000'], // same as cosmostat + 'drive_health' => ['bind' => '172.25.1.18', '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' => 5, + '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 []; + } + return $data; +} + +/* ---------- Fetch client details ---------- */ +$apiInfo = $apiConfig[$mode]; +$clients = fetchClientDetails($apiInfo['bind'], $apiInfo['port']); + +/* ---------- Handle empty / error ---------- */ +if ($clients === []) { + die('

Could not retrieve data from the API for mode "' . h($mode) . '".

'); +} + +/* ---------- 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 + } + } +} + +/* ---- 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; + + $modes = [ + 'cosmostat' => 'Cosmostat', + 'gali' => 'Shuttle Gali', + 'drive_health' => 'Drive Health', + ]; + + ?> + + +

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 '
+ '; + echo '

Drive Health - ' . h($hostname) . '

+

IP: '.h($c['ip']).'
+ Timestamp: '.date('F j, Y g:i a', (int) $c['timestamp']).'

+ '; + + if (isset($c['drives']) && is_array($c['drives']) && count($c['drives']) > 0) { + echo ''; + echo ' + '; + echo ' + '; + foreach ($c['drives'] as $drive) { + echo ' + '; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ' + '; + } + echo '
Drive LetterModelCapacityPower On HoursHost WritesWear Level
' . h($drive['drive_letter'] ?? '') . '' . h($drive['model'] ?? '') . '' . h($drive['capacity'] ?? '') . '' . h($drive['power_on_hours'] ?? '') . '' . h($drive['host_writes'] ?? '') . '' . h($drive['wear_level'] ?? '') . '
+ '; + } else { + echo '

No drive data available for this host.

'; + } + echo '
'; + } + echo '
'; + return; + } + ?> +
+ + +
+

Matt-Cloud Cosmostat Dashboard

+

This dashboard shows the local Matt-Cloud system stats.

+ +
+ +
+ Component Desriptor +

To view the component descriptor, you may
+ curl -s https:///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

+ + + +
+
    + +
  • + +
+
+

Live System Metrics

+
Connecting...
+
+
+ +
+ + +
+ +

Components

+
+ +
+

+
    + +
  • + +
+
+ +
+ +
+ + + + + +

Shuttle Gali

+ + + +
+ + + + + +Cosmostat - <?= h($selectedHost) ?> + + + +
+ + + + + + +
+ + + + + \ No newline at end of file diff --git a/files/vizz/docker/web/html/src/redis.js b/files/vizz/docker/web/html/src/redis.js new file mode 100644 index 0000000..dcfcdf0 --- /dev/null +++ b/files/vizz/docker/web/html/src/redis.js @@ -0,0 +1,145 @@ +/* ------------------------------------------------------------ + 1. Socket-IO connection & helper functions (unchanged) + ------------------------------------------------------------ */ +const socket = io(); + +socket.on('host_metrics', renderStatsTable); +socket.on('connect_error', err => { + safeSetText('host_metrics', `Could not connect to server - ${err.message}`); +}); +socket.on('reconnect', attempt => { + safeSetText('host_metrics', `Re-connected (attempt ${attempt})`); +}); + +function safeSetText(id, txt) { + const el = document.getElementById(id); + if (el) el.textContent = txt; +} + +/* ------------------------------------------------------------------ + 2. Table rendering - now orders rows before building the table + ------------------------------------------------------------------ */ +// helper function for table row ordering +function renderStatsTable(data) { + socket.emit('tableRendered'); + renderGenericTable('host_metrics', data, 'No Stats available'); +} + +function renderGenericTable(containerId, data, emptyMsg) { + const container = document.getElementById(containerId); + if (!Array.isArray(data) || !data.length) { + container.textContent = emptyMsg; + return; + } + + /* Merge rows by name (new logic) */ + const mergedData = mergeRowsByName(data); + + /* Order the merged rows – priority first */ + const orderedData = orderRows(mergedData); + + /* Build the table from the ordered data */ + const table = buildTable(orderedData); + table.id = 'host_metrics_table'; + container.innerHTML = ''; + container.appendChild(table); +} + +/* ------------------------------------------------------------ + 3. Merge rows by name + ------------------------------------------------------------ */ +function mergeRowsByName(data) { + const groups = {}; // { source: { ... } } + data.forEach(row => { + const source = row.Source; // <-- changed + if (!source) return; + if (!groups[source]) { + groups[source] = { Metric: [], Data: [] }; + } + if ('Metric' in row && 'Data' in row) { + groups[source].Metric.push(row.Metric); + groups[source].Data.push(row.Data); + } + }); + + const merged = []; + Object.entries(groups).forEach(([source, grp]) => { + merged.push({ + Source: source, // <-- keep the original key + Metric: grp.Metric, + Data: grp.Data, + Property: grp.Property, + Value: grp.Value + }); + }); + return merged; +} + +// 3b. Order rows – put “System”, “CPU”, “RAM” first +function orderRows(rows) { + // Priority list – can be updated later + const priority = ['System', 'CPU', 'RAM']; + + // Map source → priority index + const priorityMap = {}; + priority.forEach((src, idx) => { + priorityMap[src] = idx; + }); + + // Stable sort: keep original position if priorities are equal + return [...rows].sort((a, b) => { + const aIdx = priorityMap.hasOwnProperty(a.Source) + ? priorityMap[a.Source] + : Infinity; // anything not in priority goes to the end + const bIdx = priorityMap.hasOwnProperty(b.Source) + ? priorityMap[b.Source] + : Infinity; + + // If both have the same priority (or both Infinity), keep original order + return aIdx - bIdx; + }); +} + +/* ------------------------------------------------------------ + 4. Build an HTML table from an array of objects + ------------------------------------------------------------ */ +function buildTable(data) { + const cols = ['Source', 'Metric', 'Data']; // explicit order + const table = document.createElement('table'); + + // Header + const thead = table.createTHead(); + const headerRow = thead.insertRow(); + cols.forEach(col => { + const th = document.createElement('th'); + th.textContent = col; + headerRow.appendChild(th); + }); + + // Body + const tbody = table.createTBody(); + data.forEach(item => { + const tr = tbody.insertRow(); + cols.forEach(col => { + const td = tr.insertCell(); + + const val = item[col]; + if (Array.isArray(val)) { + // Create a for each item + val.forEach((v, idx) => { + td.id = 'host_metrics_column'; + const span = document.createElement('span'); + span.textContent = v; + td.appendChild(span); + + // Insert a line break after every item except the last + if (idx < val.length - 1) td.appendChild(document.createElement('br')); + }); + } else { + td.textContent = val !== undefined ? val : ''; + } + }); + }); + + return table; +} \ No newline at end of file diff --git a/files/vizz/docker/web/html/src/styles.css b/files/vizz/docker/web/html/src/styles.css new file mode 100644 index 0000000..eae1ded --- /dev/null +++ b/files/vizz/docker/web/html/src/styles.css @@ -0,0 +1,257 @@ +/* ------------------------------------------------- + 1. Global settings & color palette + ------------------------------------------------- */ +:root { + /* Dark theme - body & card backgrounds */ + --bg-body: #2c3e50; /* main page background */ + --bg-card: #34495e; /* card / panel background */ + --bg-sidebar: #3d566e; /* sidebar background (slightly lighter) */ + /* Accent / link colour */ + --clr-accent: #3498db; /* blue accent for links */ + /* Text colour */ + --clr-text: #ecf0f1; /* light whiteish text */ + /* Borders / accents */ + --clr-border: #1f2b38; /* dark border colour */ +} + +* { box-sizing: border-box; } + +/* Body */ +body { + margin: 0; + padding: 0; + background: var(--bg-body); + color: var(--clr-text); + font-family: Arial, Helvetica, sans-serif; +} + +/* Links */ +a { color: var(--clr-accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ------------------------------------------------- + 2. Layout - wrapper, sidebar, main + ------------------------------------------------- */ +.wrapper { display: flex; } + +.sidebar { + position: fixed; /* keep sidebar visible during scroll */ + top: 0; /* stick to the top of the viewport */ + left: 0; /* align to the left edge */ + height: 100vh; /* full viewport height */ + /* ---- size & spacing ------------------------------------------- */ + width: 200px; /* same as before */ + padding: 1rem; + overflow-y: auto; /* allow sidebar content to scroll if needed */ + /* ---- look ------------------------------------------------------- */ + background: var(--bg-sidebar); + /* optional: keep it above other content */ + z-index: 1000; +} +.sidebar h3 { margin: 0 0 .4rem 0; font-size: 1.1rem; } +.sidebar ul { list-style: none; padding: 0; margin: 0; } +.sidebar ol { list-style: none; padding: 0; margin: 0; } +.sidebar li { margin-bottom: .4rem; } +.sidebar a { color: var(--clr-accent); } +.sidebar a.active { font-weight: bold; } + +.main{ + flex: 1; + padding: 1rem; + padding-left: 200px; /* space for the fixed sidebar */ + /* optional: avoid accidental horizontal overflow */ + overflow-x: hidden; +} +/* ------------------------------------------------- + 3. Card component + ------------------------------------------------- */ +.card { + max-width: 950px; + margin: 20px auto 1rem auto; + padding: 20px; + background: var(--bg-card); + border: 1px solid var(--clr-border); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,.3); +} + +/* ------------------------------------------------- + 4. Tables + ------------------------------------------------- */ +table, th, td { + border: 2px solid var(--clr-border); + border-collapse: collapse; +} +th, td { padding: 10px; } + +/* Alternate row colour for metrics table */ +#host_metrics_table tbody tr td:nth-of-type(even) { + background: #3e5c78; /* slight contrast */ +} + +/* ------------------------------------------------- + 5. Lists & headings + ------------------------------------------------- */ +h1, h2, h3, h4 { color: var(--clr-text); margin: 0 0 .4rem 0; } +ul { list-style: none; padding: 0; } +ol { list-style: none; padding: 0; } +li { margin-bottom: 10px; color: var(--clr-text); } + +/* System & component lists */ +.system-list, .info-list { + list-style: none; padding: 0; margin: 0; +} +.system-list li, .info-list li { margin-bottom: 5px; color: var(--clr-text); } + +/* ------------------------------------------------- + 6. Components grid + ------------------------------------------------- */ +.components { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} +.component { + padding: 10px; + border: 1px solid var(--clr-border); + border-radius: 4px; +} +.component h3 { margin: 0 0 5px; } + +/* ------------------------------------------------- + 7. Panel toggles / modal + ------------------------------------------------- */ +.help-link { + cursor: pointer; + user-select: none; + color: var(--clr-accent); + text-align: right; +} +.help-link:hover { text-decoration: underline; } +#helpText { display: none; } + +.componentDetail-link { + cursor: pointer; + user-select: none; + color: var(--clr-accent); + text-align: left; +} +.componentDetail-link:hover { text-decoration: underline; } +#componentDetailText { display: none; } + + +/* ------------------------------------------------- + 8. Misc helpers + ------------------------------------------------- */ +/* Hide numeric markers in metric columns (if any) */ +#host_metrics_column td { list-style: none; padding-left: 0; margin-left: 0; } + +.host-status { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-left: 6px; + margin-right: 8px; + vertical-align: middle; + background: #808080; /* default – unknown / no timestamp */ + transition: background-color 1s linear; /* smooth fade */ +} + + +/* ------------------------------------------------- + 9. Mobile adjustments + ------------------------------------------------- */ +@media (max-width: 768px) { + /* 1. Make the whole page a column */ + .wrapper { + flex-direction: column; + } + + /* 2. Hide the sidebar initially */ + .sidebar { + position: relative; /* take it out of the flow */ + width: 100%; + max-height: 0; /* collapsed */ + overflow: hidden; + transition: max-height 0.3s ease-out; + background: var(--bg-sidebar); + padding: 0; /* remove padding */ + } + + .sidebar.show { + max-height: 500px; /* enough for all items */ + padding: 1rem; + } + + /* 3. Move the toggle button into the header */ + .mobile-toggle { + display: block; + font-size: 1.5rem; + background: transparent; + border: none; + color: var(--clr-accent); + padding: 0.5rem; + margin-bottom: 0.5rem; + } + + /* 4. Main content no left padding */ + .main { + padding-left: 0; + padding-right: 1rem; + } + + /* 5. Table scroll on small screens */ + .card table { + width: 100%; + table-layout: fixed; + } + + .card table thead, + .card table tbody, + .card table tr, + .card table td, + .card table th { + display: block; + } + + .card table tbody { + overflow-x: auto; + white-space: nowrap; + } + + .card table td, + .card table th { + display: inline-block; + vertical-align: top; + width: 30%; + } + + /* 6. Adjust the host status dot positioning */ + .host-status { + margin-left: 2px; + margin-right: 12px; + } +} + +/* Optional: style the drawer indicator */ +.sidebar.show::before { + content: "✕ Close"; + display: block; + padding: 0.5rem 1rem; + background: var(--bg-sidebar); + color: var(--clr-accent); + font-weight: bold; +} +/* for stacking storage clients */ +.storage_client{ + + flex: 1; + padding: 1rem; + padding-left: 200px; /* space for the fixed sidebar */ + overflow-x: hidden; + display: flex; /* make it a flex container */ + flex-direction: column; /* stack its children (cards) vertically */ + align-items: center; /* center the cards horizontally */ + +} diff --git a/files/vizz/docker/web/html/src/system_metrics.js b/files/vizz/docker/web/html/src/system_metrics.js new file mode 100644 index 0000000..e72847e --- /dev/null +++ b/files/vizz/docker/web/html/src/system_metrics.js @@ -0,0 +1,317 @@ +/* ============================================================== + system_metrics.js + ============================================================== */ +(() => { + /* ========================================================== + Socket.IO setup + ========================================================== */ + const socket = io({ + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 3000, + reconnectionDelayMax: 60000, + timeout: 60000, + pingTimeout: 5000, + pingInterval: 25000, + }); + /* ========================================================== + Color constants + ========================================================== */ + const GREEN = [ 39, 174, 96]; // #27ae60 + const YELLOW = [243, 156, 18]; // #f39c12 + const RED = [192, 57, 43]; // #c0392b + /* ========================================================== + Helpers + ========================================================== */ + const hostTimestamps = {}; // keyed by short_id + const toRgb = (r, g, b) => `rgb(${r},${g},${b})`; + const T20 = 20 * 1000; + const T40 = 40 * 1000; + const T60 = 60 * 1000; + function getFreshnessColor(ageMs) { + if (ageMs <= T20) { + return toRgb(...GREEN); + } + if (ageMs <= T40) { + const t = (ageMs - T20) / (T40 - T20); + const r = Math.round(GREEN[0] + t * (YELLOW[0] - GREEN[0])); + const g = Math.round(GREEN[1] + t * (YELLOW[1] - GREEN[1])); + const b = Math.round(GREEN[2] + t * (YELLOW[2] - GREEN[2])); + return toRgb(r, g, b); + } + if (ageMs <= T60) { + const t = (ageMs - T40) / (T60 - T40); + const r = Math.round(YELLOW[0] + t * (RED[0] - YELLOW[0])); + const g = Math.round(YELLOW[1] + t * (RED[1] - YELLOW[1])); + const b = Math.round(YELLOW[2] + t * (RED[2] - YELLOW[2])); + return toRgb(r, g, b); + } + return toRgb(...RED); + } + function safeSetText(id, txt) { + const el = document.getElementById(id); + if (el) el.textContent = txt; + } + /* ========================================================== + Get the short_id from the query string + ========================================================== */ + function getSelectedId() { + return new URLSearchParams(window.location.search).get('host') || ''; + } + /* ========================================================== + Sidebar building - uses short_id for status key + ========================================================== */ + + function buildList(systemList) { + const ul = document.getElementById('endpointList'); + + if (!Array.isArray(systemList)) { + ul.innerHTML = ''; // nothing to show + return; + } + + /* ──────────────────────────────────────── + * Sort: servers first, then by IP + * ──────────────────────────────────────── */ + const toInt = ip => { + // guard against undefined / empty string + if (!ip) return 0; + return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0); + }; + + const sorted = [...systemList].sort((a, b) => { + // a. Servers go before non‑servers + const aServer = !!a.is_server; + const bServer = !!b.is_server; + if (aServer !== bServer) return aServer ? -1 : 1; // true < false + + // b. Same “is_server” status – fall back to IP sorting + const aIp = a.active_ip ?? ''; + const bIp = b.active_ip ?? ''; + + if (!aIp) return 1; // push empty IPs to the end + if (!bIp) return -1; + + return toInt(aIp) - toInt(bIp); + }); + + /* ──────────────────────────────────────── + * Bail if nothing actually changed + * ──────────────────────────────────────── */ + const current = Array.from(ul.children).map(li => li.dataset.id); + const newIds = sorted.map(s => s.short_id); + if (arraysEqual(current, newIds)) return; // no visual change needed + + /* ──────────────────────────────────────── + * Build the DOM + * ──────────────────────────────────────── */ + const selected = getSelectedId().toLowerCase(); + ul.innerHTML = ''; // reset + + sorted.forEach(item => { + const li = document.createElement('li'); + + // • Status dot + const status = document.createElement('span'); + status.className = 'host-status'; + status.dataset.id = item.short_id; + + // • Link – display hostname, encode short_id in URL + const a = document.createElement('a'); + a.href = '?host=' + encodeURIComponent(item.short_id); + a.textContent = item.hostname; + a.title = item.active_ip ? `Active IP: ${item.active_ip}` : ''; + if (item.short_id.toLowerCase() === selected) a.classList.add('active'); + + li.appendChild(status); + li.appendChild(a); + ul.appendChild(li); + }); + } + + /* ========================================================== + Update status colors every second + ========================================================== */ + function updateStatusColors() { + const nowSec = Date.now() / 1000; + Object.entries(hostTimestamps).forEach(([id, ts]) => { + const ageMs = (nowSec - ts) * 1000; + const color = getFreshnessColor(ageMs); + const span = document.querySelector( + `.host-status[data-id="${id}"]` + ); + if (span) span.style.backgroundColor = color; + }); + } + setInterval(updateStatusColors, 1000); + /* ========================================================== + Utility helpers + ========================================================== */ + function arraysEqual(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; + } + function renderGenericTable(containerId, data, emptyMsg) { + const container = document.getElementById(containerId); + if (!Array.isArray(data) || !data.length) { + container.textContent = emptyMsg; + return; + } + const merged = mergeRowsByName(data); + const ordered = orderRows(merged); + const table = buildTable(ordered); + table.id = 'host_metrics_table'; + container.innerHTML = ''; + container.appendChild(table); + } + function mergeRowsByName(rows) { + const groups = {}; // { Source: { Metric: [], Data: [] } } + rows.forEach(r => { + const src = r.Source; + if (!src) return; + if (!groups[src]) groups[src] = { Metric: [], Data: [] }; + if ('Metric' in r && 'Data' in r) { + groups[src].Metric.push(r.Metric); + groups[src].Data.push(r.Data); + } + }); + return Object.entries(groups).map(([src, g]) => ({ + Source: src, + Metric: g.Metric, + Data: g.Data, + })); + } + function orderRows(rows) { + const priority = ['System', 'CPU', 'RAM']; + const map = {}; + priority.forEach((s, i) => map[s] = i); + return [...rows].sort((a, b) => { + const ai = map.hasOwnProperty(a.Source) ? map[a.Source] : Infinity; + const bi = map.hasOwnProperty(b.Source) ? map[b.Source] : Infinity; + return ai - bi; + }); + } + function buildTable(rows) { + const cols = ['Source', 'Metric', 'Data']; + const table = document.createElement('table'); + // Header + const thead = table.createTHead(); + const headerRow = thead.insertRow(); + cols.forEach(col => { + const th = document.createElement('th'); + th.textContent = col; + headerRow.appendChild(th); + }); + // Body + const tbody = table.createTBody(); + rows.forEach(item => { + const tr = tbody.insertRow(); + cols.forEach(col => { + const td = tr.insertCell(); + const val = item[col]; + if (Array.isArray(val)) { + val.forEach((v, i) => { + const span = document.createElement('span'); + span.textContent = v; + td.appendChild(span); + if (i < val.length - 1) td.appendChild(document.createElement('br')); + }); + } else { + td.textContent = val !== undefined ? val : ''; + } + }); + }); + return table; + } + /* ========================================================== + Handle incoming data + ========================================================== */ + let lastUpdate = Date.now(); + function handleSummary(raw) { + lastUpdate = Date.now(); // reset watchdog + let payload; + if (typeof raw === 'string') { + try { payload = JSON.parse(raw); } catch (e) { + safeSetText('client_summary', 'Invalid data received'); + return; + } + } else payload = raw; + if (!Array.isArray(payload) || !payload.length) { + safeSetText('client_summary', 'No data available'); + return; + } + // Build the list first (so elements exist) + buildList(payload); + // Store the timestamp for every short_id + payload.forEach(hostObj => { + if (hostObj.short_id && hostObj.data_timestamp) { + hostTimestamps[hostObj.short_id] = hostObj.data_timestamp; // seconds + } + }); + // Immediately update colors for the current view + updateStatusColors(); + // Metric table for selected host + const selectedId = getSelectedId(); + const hostObj = payload.find(h => h.short_id === selectedId) || payload[0]; + const hostData = hostObj && Array.isArray(hostObj.redis_data) + ? hostObj.redis_data + : []; + renderGenericTable('host_metrics', hostData, 'No Stats available'); + } + /* ========================================================== + Socket event wiring + ========================================================== */ + socket.on('client_summary', handleSummary); + socket.on('connect', () => { + safeSetText('client_summary', 'Connected'); + requestSummary(); + }); + socket.on('disconnect', () => { + safeSetText('client_summary', 'Disconnected - retrying...'); + }); + socket.on('reconnect', attempt => { + safeSetText('client_summary', `Re-connected (attempt ${attempt})`); + requestSummary(); + }); + /* ========================================================== + Request logic + ========================================================== */ + function requestSummary() { + if (!socket.connected) return; // guard against stale emits + socket.emit('get_client_summary'); // server will reply via client_summary + } + /* ========================================================== + Recursive polling + ========================================================== */ + let pollTimer = null; + function pollLoop() { + if (!socket.connected) return; + requestSummary(); + pollTimer = setTimeout(pollLoop, 5000); + } + socket.on('connect', () => { + if (!pollTimer) pollLoop(); + }); + /* ========================================================== + Watchdog - force reconnect if no data for 15 s + ========================================================== */ + function watchdog() { + if (Date.now() - lastUpdate > 15000 && socket.connected) { + safeSetText('client_summary', 'No updates - reconnecting...'); + socket.disconnect(); // forces a reconnect cycle + } + setTimeout(watchdog, 5000); + } + watchdog(); + /* ========================================================== + Keep the 'active' link in sync when the URL changes + ========================================================== */ + window.addEventListener('popstate', () => { + const selected = getSelectedId().toLowerCase(); + document.querySelectorAll('#endpointList a').forEach(a => + a.classList.toggle('active', a.href.includes('host=' + encodeURIComponent(selected))) + ); + }); +})(); \ No newline at end of file diff --git a/files/vizz/docker/web/html/test.php b/files/vizz/docker/web/html/test.php new file mode 100644 index 0000000..e996e21 --- /dev/null +++ b/files/vizz/docker/web/html/test.php @@ -0,0 +1,324 @@ + ['bind' => '10.200.27.20', 'port' => '5000'], + 'gali' => ['bind' => '10.200.27.20', 'port' => '5000'], // same as cosmostat + 'drive_health' => ['bind' => '172.25.1.18', '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' => 5, + 'header' => "User-Agent: PHP/" . PHP_VERSION . "\r\n" + ] + ]); + $json = @file_get_contents($url, false, $ctx); + if ($json === false) { + return []; + } + $data = json_decode($json, true); + return (is_array($data)) ? $data : []; +} + +/* ---------- Fetch client details ---------- */ +$apiInfo = $apiConfig[$mode]; +$clients = fetchClientDetails($apiInfo['bind'], $apiInfo['port']); + +/* ---------- Handle empty / error ---------- */ +if ($clients === []) { + die('

Could not retrieve data from the API for mode "' . h($mode) . '".

'); +} + +/* ---------- 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'] ?? []; // default: whatever was submitted + +if ($mode === 'drive_health') { + if (isset($_GET['action'])) { + switch ($_GET['action']) { + case 'all': + // add every client’s short_id + $selectedHosts = array_column($clients, 'short_id'); + break; + case 'none': + $selectedHosts = []; // nothing selected + break; + // 'apply' does nothing extra – the array above already contains the chosen hosts + } + } +} + +/* ---------- Determine selected host for other modes ---------- */ +$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) { + $selectedIdx = 0; + $selectedId = $clients[$selectedIdx]['short_id'] ?? ''; +} + +$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, $selectedId, $selectedIdx, $selectedHosts; + + $modes = [ + 'cosmostat' => 'Cosmostat', + 'gali' => 'Shuttle Gali', + 'drive_health' => 'Drive Health', + ]; + ?> + +

No hosts selected.

'; + return; + } + + 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 '
'; + echo '

Drive Health - ' . h($hostname) . '

'; + + if (isset($c['drives']) && is_array($c['drives']) && count($c['drives']) > 0) { + echo ''; + echo ''; + echo ''; + foreach ($c['drives'] as $drive) { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
Drive LetterModelCapacityPower On HoursHost WritesWear Level
' . h($drive['drive_letter'] ?? '') . '' . h($drive['model'] ?? '') . '' . h($drive['capacity'] ?? '') . '' . h($drive['power_on_hours'] ?? '') . '' . h($drive['host_writes'] ?? '') . '' . h($drive['wear_level'] ?? '') . '
'; + } else { + echo '

No drive data available for this host.

'; + } + echo '
'; + } + return; + } + + /* ---------- Other modes ---------- */ + ?> +
+ + +
+

Matt-Cloud Cosmostat Dashboard

+

This dashboard shows the local Matt-Cloud system stats.

+ +
+ + +
+ Component Desriptor +

To view the component descriptor, you may
+ curl -s https:///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

+ + + +
+
    + +
  • + +
+
+

Live System Metrics

+
Connecting...
+
+
+ +
+ + +
+ +

Components

+
+ +
+

+
    + +
  • + +
+
+ +
+ +
+ + + +

Shuttle Gali

+ +
+ + + + + +Cosmostat - <?= h($selectedHost) ?> (<?= h($mode) ?>) + + + +
+ + + + + +
+ + + + + \ No newline at end of file diff --git a/files/vizz/docker/web/node_server/package.json b/files/vizz/docker/web/node_server/package.json new file mode 100644 index 0000000..3d33e2b --- /dev/null +++ b/files/vizz/docker/web/node_server/package.json @@ -0,0 +1,15 @@ +{ + "name": "redis-table-demo", + "version": "1.0.0", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "socket.io": "^4.7.2", + "redis": "^4.6.7", + "node-fetch": "^2.6.7", + "js-yaml": "^4.1.0" + } +} \ No newline at end of file diff --git a/files/vizz/docker/web/node_server/server.js b/files/vizz/docker/web/node_server/server.js new file mode 100644 index 0000000..047c167 --- /dev/null +++ b/files/vizz/docker/web/node_server/server.js @@ -0,0 +1,153 @@ +// server.js +const http = require('http'); +const express = require('express'); +const { createClient } = require('redis'); +const { Server } = require('socket.io'); +const fetch = require('node-fetch'); // npm i node-fetch@2 +const fs = require('fs'); +const yaml = require('js-yaml'); // npm i js-yaml +const path = require('path'); + +const app = express(); +const server = http.createServer(app); +const io = new Server(server); + +/* --------------------------------------------------------------------- */ +/* ---------- 1. Load the YAML configuration file ---------------------- */ +/* --------------------------------------------------------------------- */ +let config = {}; +try { + const filePath = '/app/cosmostat_settings.yaml'; + const file = fs.readFileSync(filePath, 'utf8'); + config = yaml.load(file); +} catch (e) { + console.error('Failed to load config.yaml:', e); + process.exit(1); +} +const API_PORT = config.custom_api_port || 5000; // fallback to 5000 +const API_HOST = config.api_bind_ip || '192.168.37.1'; // fallback IP +const API_BASE = `http://${API_HOST}:${API_PORT}`; +console.log('API URL:', API_BASE); + +/* --------------------------------------------------------------------- */ +/* ---------- 2. Socket.io -------------------------------------------- */ +/* --------------------------------------------------------------------- */ +io.on('connection', async socket => { + console.log('client connected:', socket.id); + + /* ------------- send cached client_summary ------------- */ + if (clientSummaryCache.last) { + socket.emit('client_summary', clientSummaryCache.last); + console.log('sent cached client_summary to', socket.id); + } + + /* ----------------------------------------------------------------- */ + /* Call the external API every time a client connects */ + /* ----------------------------------------------------------------- */ + try { + const resp = await fetch(`${API_BASE}/start_timer`, { method: 'GET' }); + const data = await resp.json(); + console.log('API responded to connect:', data); + } catch (err) { + console.error('Failed to hit start_timer endpoint:', err); + } + + /* ----------------------------------------------------------------- */ + /* Listen for tableRendered event from the client */ + /* ----------------------------------------------------------------- */ + socket.on('tableRendered', async () => { + console.log('Client reported table rendered - starting timer'); + try { + const resp = await fetch(`${API_BASE}/start_timer`, { method: 'GET' }); + const text = await resp.text(); + console.log('Timer endpoint responded:', text); + } catch (err) { + console.error('Failed to hit start_timer:', err); + } + }); +}); + +/* --------------------------------------------------------------------- */ +/* ---------- 3. Serve static files ----------------------------------- */ +/* --------------------------------------------------------------------- */ +app.use(express.static('public')); + +/* --------------------------------------------------------------------- */ +/* ---------- 4. Redis subscriber ------------------------------------- */ +/* --------------------------------------------------------------------- */ +const redisClient = createClient({ + url: 'redis://192.168.37.1:6379', + socket: { keepAlive: 60000, // 60 s TCP keep-alive + reconnectStrategy: attempts => Math.min(attempts * 100, 3000) } // back-off +}); +redisClient.on('error', err => console.error('Redis error', err)); + +/* --- local cache for client_summary -------------------------------- */ +const clientSummaryCache = {}; // { last: } + +/* --------------------------------------------------------------------- */ +(async () => { + await redisClient.connect(); + const sub = redisClient.duplicate(); + await sub.connect(); + + /* --------------------------------------------------------------------- */ + /* Helper that re-subscribes to a channel (and re-sends the handler) */ + /* --------------------------------------------------------------------- */ + async function safeSubscribe(channel, handler) { + try { + await sub.subscribe(channel, handler); + console.log(`Subscribed to ${channel}`); + } catch (e) { + console.error(`Failed to subscribe to ${channel}`, e); + } + } + + /* --------------------------------------------------------------------- */ + /* Subscribe to all required channels */ + /* --------------------------------------------------------------------- */ + await safeSubscribe('host_metrics', (msg) => forward('host_metrics', msg)); + await safeSubscribe('client_summary', (msg) => forward('client_summary', msg)); + + /* --------------------------------------------------------------------- */ + /* Forward messages to Socket.io */ + /* --------------------------------------------------------------------- */ + function forward(channel, message) { + try { + const payload = JSON.parse(message); + + /* ----- update cache on client_summary ----- */ + if (channel === 'client_summary') { + clientSummaryCache.last = payload; + } + + io.emit(channel, payload); + } catch (e) { + console.error(`Failed to parse message from ${channel}`, e); + } + } + + /* --------------------------------------------------------------------- */ + /* Re-subscribe automatically when the Redis connection reconnects */ + /* --------------------------------------------------------------------- */ + sub.on('reconnecting', () => console.log('Redis reconnecting…')); + sub.on('ready', async () => { + console.log('Redis ready - re-subscribing to all channels'); + await safeSubscribe('host_metrics', (msg) => forward('host_metrics', msg)); + await safeSubscribe('client_summary', (msg) => forward('client_summary', msg)); + }); + + /* Optional: if the connection ends for any reason, close the process */ + sub.on('end', () => { + console.error('Redis connection closed - exiting'); + process.exit(1); + }); +})(); + +/* --------------------------------------------------------------------- */ +/* ---------- 5. Start the HTTP server --------------------------------- */ +/* --------------------------------------------------------------------- */ +const PORT = process.env.PORT || 3000; +server.listen(PORT, () => { + console.log(`Server listening on http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/files/vizz/docker/web/proxy/nginx.conf b/files/vizz/docker/web/proxy/nginx.conf new file mode 100644 index 0000000..c442dfa --- /dev/null +++ b/files/vizz/docker/web/proxy/nginx.conf @@ -0,0 +1,63 @@ +# nginx.conf +# This file will be mounted into /etc/nginx/conf.d/default.conf inside the container + +# Enable proxy buffers (optional but recommended) +proxy_buffering on; +proxy_buffers 16 16k; +proxy_buffer_size 32k; + +server { +listen 80; +server_name localhost; + + # --------------------------------------- + # API Endpoints + # --------------------------------------- + location = /descriptor { + proxy_pass http://192.168.37.1:5000/descriptor; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location = /update_client { + proxy_pass http://192.168.37.1:5000/update_client; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location = /create_client { + proxy_pass http://192.168.37.1:5000/create_client; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --------------------------------------- + # WebSocket endpoint + # --------------------------------------- + location /socket.io/ { + proxy_pass http://0.0.0.0:3000/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --------------------------------------- + # All other paths → Apache (PHP) + # --------------------------------------- + location / { + proxy_pass http://0.0.0.0:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + +} + \ No newline at end of file diff --git a/tasks/api.yaml b/tasks/api.yaml index a0a6dc3..e43fed0 100644 --- a/tasks/api.yaml +++ b/tasks/api.yaml @@ -5,22 +5,10 @@ api_bind_ip: "{{ cosmostat_server_ip }}" - name: Cosmostat - API - Stop Service - become: true - become_user: "{{ service_user }}" ignore_errors: yes systemd: name: "{{ api_service_name }}.service" state: stopped - scope: user - -# create service working folder -- name: Cosmostat - API - create cosmos user systemd folder - file: - path: "{{ user_service_folder }}" - state: directory - owner: "{{ service_user }}" - group: "{{ service_user }}" - mode: '0755' - name: Cosmostat - API - copy api files copy: @@ -30,7 +18,9 @@ group: "{{ service_user }}" mode: 0755 +# skip this when updating to keep any settings - name: "Cosmostat - API - template cosmostat_settings.yaml" + when: not update_pipeline | bool template: src: cosmostat_settings.yaml dest: "{{ api_service_folder }}/cosmostat_settings.yaml" @@ -43,24 +33,18 @@ service_name: "{{ api_service_name }}" service_working_folder: "{{ api_service_folder }}" service_exe: "{{ api_service_exe }}" - service_group: "{{ service_user }}" extra_options: "" extra_service_options: "RestartSec=5" template: src: "service_template.service" - dest: "{{ user_service_folder }}/{{ api_service_name }}.service" - owner: "{{ service_user }}" - group: "{{ service_user }}" + dest: "/etc/systemd/system/{{ api_service_name }}.service" mode: 0644 - name: Cosmostat - API - Daemon Reload, Start, Enable - become: true - become_user: "{{ service_user }}" systemd: daemon_reload: yes name: "{{ api_service_name }}.service" state: started enabled: yes - scope: user ... \ No newline at end of file diff --git a/tasks/docker.yaml b/tasks/docker.yaml index 2fecd67..ce12b97 100644 --- a/tasks/docker.yaml +++ b/tasks/docker.yaml @@ -15,6 +15,7 @@ name: "cosmostat-dash" tag: latest state: absent + ignore_errors: yes # Create web Folder - name: "Cosmostat - Web - create {{ service_control_docker_folder }}" @@ -39,42 +40,17 @@ dest: "{{ service_control_docker_folder }}/docker-compose.yaml" mode: 0644 +# skip this when updating to keep any settings - name: "Cosmostat - Web - template cosmostat_settings.yaml" + when: not update_pipeline | bool template: src: cosmostat_settings.yaml dest: "{{ service_control_docker_folder }}/cosmostat_settings.yaml" owner: "{{ service_user }}" group: "{{ service_user }}" mode: 0644 - -####################### -# configure as server -- name: Cosmostat - Web - Configure Server Dashboard - when: cosmostat_server | bool - block: - - - name: Cosmostat - Server Dashboard - replace index.php - copy: - src: server/server.php - dest: "{{ service_control_docker_folder }}/web/html/index.php" - mode: 0755 - owner: "{{ service_user }}" - group: "{{ service_user }}" - - - name: Cosmostat - Server Dashboard - delete redis.js - ansible.builtin.file: - path: "{{ service_control_docker_folder }}/web/html/src/redis.js" - state: absent - - - name: Cosmostat - Server Dashboard - copy system_metrics.js - copy: - src: server/system_metrics.js - dest: "{{ service_control_docker_folder }}/web/html/src/system_metrics.js" - mode: 0755 - owner: "{{ service_user }}" - group: "{{ service_user }}" -- name: Cosmostat - Web - Start containers +- name: Cosmostat - Web - Start container community.docker.docker_compose_v2: project_src: "{{ service_control_docker_folder }}" state: present diff --git a/tasks/init.yaml b/tasks/init.yaml index 1af1893..1c4a49c 100644 --- a/tasks/init.yaml +++ b/tasks/init.yaml @@ -10,21 +10,24 @@ x64_arch: false # package handler -- name: Cosmostat - Init - Get installed package list - shell: "dpkg --list | grep ii | awk '{print $2}'" - register: dpkg_output - -- name: Cosmostat - Init - Install Prereq Packages - when: - - cosmostat_packages_item not in dpkg_output.stdout_lines - - cosmostat_packages_item | length > 0 - apt: - name: - - "{{ cosmostat_packages_item }}" - state: present - loop: "{{ cosmostat_packages }}" - loop_control: - loop_var: cosmostat_packages_item +- name: server user and folder handlers + when: not quick_refresh | bool + block: + - name: Cosmostat - Init - Get installed package list + shell: "dpkg --list | grep ii | awk '{print $2}'" + register: dpkg_output + + - name: Cosmostat - Init - Install Prereq Packages + when: + - cosmostat_packages_item not in dpkg_output.stdout_lines + - cosmostat_packages_item | length > 0 + apt: + name: + - "{{ cosmostat_packages_item }}" + state: present + loop: "{{ cosmostat_packages }}" + loop_control: + loop_var: cosmostat_packages_item # docker network for cosmostat service - name: Cosmostat - Init - Check for docker network @@ -32,7 +35,9 @@ register: docker_network_register - name: Cosmostat - Init - Run Network Handlers - when: docker_network_register.stdout | int == 0 + when: + - docker_network_register.stdout | int == 0 + - not quick_refresh | bool block: - name: Cosmostat - Init - Set Up docker network x64 @@ -47,40 +52,44 @@ when: not x64_arch | bool shell: "docker network create --driver bridge --subnet {{ docker_subnet }} cosmostat_net" -# allow service_user to sudo lshw without a password -- name: Cosmostat - Init - cosmos user sudoers file creation - copy: - dest: "/etc/sudoers.d/cosmostat" - content: "{{ cosmostat_sudoers_content }}" - owner: root - group: root - mode: "0600" +- name: server user and folder handlers + when: not quick_refresh | bool + block: + # allow service_user to sudo lshw without a password + - name: Cosmostat - Init - cosmos user sudoers file creation + copy: + dest: "/etc/sudoers.d/cosmostat" + content: "{{ cosmostat_sudoers_content }}" + owner: root + group: root + mode: "0600" -# allow user services to "linger" -- name: Cosmostat - Init - cosmos user enable linger - shell: "loginctl enable-linger {{ service_user }}" - register: user_linger + # allow user services to "linger" + - name: Cosmostat - Init - cosmos user enable linger + shell: "loginctl enable-linger {{ service_user }}" + register: user_linger -# create service working folder -- name: Cosmostat - Init - create cosmostat service folder - file: - path: "{{ service_folder }}" - state: directory - owner: "{{ service_user }}" - group: "{{ service_user }}" - mode: '0755' + # create service working folder + - name: Cosmostat - Init - create cosmostat service folder + file: + path: "{{ service_folder }}" + state: directory + owner: "{{ service_user }}" + group: "{{ service_user }}" + mode: '0755' -# create user service folder -- name: Cosmostat - Init - create cosmostat user service folder - file: - path: "{{ user_service_folder }}" - state: directory - owner: "{{ service_user }}" - group: "{{ service_user }}" - mode: '0755' + # create user service folder + - name: Cosmostat - Init - create cosmostat user service folder + file: + path: "{{ user_service_folder }}" + state: directory + owner: "{{ service_user }}" + group: "{{ service_user }}" + mode: '0755' # Create python service venv - name: Cosmostat - Init - Build Python Environment + when: not quick_refresh | bool block: - name: "Cosmostat - Init - create python venv folder at {{ venv_folder }}" @@ -108,4 +117,23 @@ virtualenv_command: python3 -m venv state: present +# remove web containers if disable_local_dashboard +- name: Cosmostat - Init - container handler + when: disable_local_dashboard | bool + block: + + - name: Cosmostat - Init - stop containers + community.docker.docker_compose_v2: + project_src: "{{ service_control_docker_folder }}" + state: stopped + ignore_errors: true + + - name: Cosmostat - Init - Remove Cosmostat Image + community.docker.docker_image: + name: "cosmostat-dash" + tag: latest + state: absent + ignore_errors: true + + ... \ No newline at end of file diff --git a/tasks/main.yaml b/tasks/main.yaml index bbcc28d..e6f97ef 100644 --- a/tasks/main.yaml +++ b/tasks/main.yaml @@ -1,15 +1,15 @@ --- -# refresh when refresh -#- name: Quick refresh -# when: refresh_special | bool -# set_fact: -# quick_refresh: true +# new big dashboard test +#- name: MC Vizz +# include_tasks: vizz.yaml -# initializa environment -- name: Initialize Environment - when: not quick_refresh | bool - include_tasks: init.yaml +- name: disable + when: false + block: + # initialize environment + - name: Initialize Environment + include_tasks: init.yaml # set up API - name: Build API @@ -17,7 +17,9 @@ # set up web stack - name: Build Web Dashboard - when: not disable_local_dashboard | bool + when: + - not disable_local_dashboard | bool + - not update_pipeline | bool include_tasks: docker.yaml ... \ No newline at end of file diff --git a/tasks/vizz.yaml b/tasks/vizz.yaml new file mode 100644 index 0000000..aecfbfc --- /dev/null +++ b/tasks/vizz.yaml @@ -0,0 +1,55 @@ +--- +############################################### +# Full MC-Vizz Dashboard +############################################### + +- name: Cosmostat - Web - stop containers + community.docker.docker_compose_v2: + project_src: "{{ service_control_vizz_folder }}" + state: "{{ 'stopped' if quick_refresh | bool else 'absent' }}" + ignore_errors: yes + +# Create web Folder +- name: "Cosmostat - Web - create {{ service_control_vizz_folder }}" + file: + path: "{{ service_control_vizz_folder }}" + state: directory + mode: '0755' + owner: "{{ service_user }}" + group: "{{ service_user }}" + +- name: Cosmostat - Web - copy web files + copy: + src: "vizz/docker/" + dest: "{{ service_control_vizz_folder }}/" + mode: 0755 + owner: "{{ service_user }}" + group: "{{ service_user }}" + +- name: Cosmostat - Web - template docker-compose.yaml + template: + src: docker-compose-vizz.yaml + dest: "{{ service_control_vizz_folder }}/docker-compose.yaml" + mode: 0644 + +# skip this when updating to keep any settings +- name: "Cosmostat - Web - template cosmostat_settings.yaml" + when: not update_pipeline | bool + template: + src: cosmostat_settings.yaml + dest: "{{ service_control_vizz_folder }}/cosmostat_settings.yaml" + owner: "{{ service_user }}" + group: "{{ service_user }}" + mode: 0644 + +- name: Cosmostat - Web - Start container + community.docker.docker_compose_v2: + project_src: "{{ service_control_vizz_folder }}" + state: present + build: "{{ 'always' if not quick_refresh | bool else 'never' }}" + register: docker_output +- debug: | + msg="{{ docker_output.actions }}" + + +... \ No newline at end of file diff --git a/templates/docker-compose-vizz.yaml b/templates/docker-compose-vizz.yaml new file mode 100644 index 0000000..ef5b9cd --- /dev/null +++ b/templates/docker-compose-vizz.yaml @@ -0,0 +1,29 @@ +--- +name: mc-vizz +services: + + mx-vizz-dash: + build: + context: . + dockerfile: Dockerfile + container_name: mc-vizz-dash + image: mc-vizz-dash:latest + restart: always + networks: + - cosmostat_net + ports: +# - "{{ docker_gateway }}:6379:6379" + - "{{ mcvizz_web_port }}:80" + # When the container is built in Ansible, these are all copied + # if any changes are made manually on the endpoint, uncomment as needed + #volumes: + # - "/opt/cosmostat/api/cosmostat_settings.yaml:/app/cosmostat_settings.yaml:ro" + # - "/opt/cosmostat/api/cosmostat_settings.yaml:/usr/src/app/cosmostat_settings.yaml:ro" + # - "/opt/cosmostat/docker/web/html:/var/www/html" + # - "/opt/cosmostat/docker/web/node_server:/app" +#{{ (docker_gateway + ':') if not public_dashboard | bool else '' }} +networks: + cosmostat_net: + external: true + +... \ No newline at end of file diff --git a/templates/docker-compose.yaml b/templates/docker-compose.yaml index a98df89..6ce0954 100644 --- a/templates/docker-compose.yaml +++ b/templates/docker-compose.yaml @@ -1,5 +1,5 @@ --- - +name: cosmostat services: cosmostat-dash: diff --git a/templates/service_template.service b/templates/service_template.service index cb1f3c1..76412de 100644 --- a/templates/service_template.service +++ b/templates/service_template.service @@ -5,6 +5,8 @@ After=network.target {{ extra_options }} [Service] +User={{ service_user }} +Group={{ service_user }} WorkingDirectory={{ service_working_folder }} ExecStart={{ service_exe }} Restart=always