diff --git a/defaults/main.yaml b/defaults/main.yaml index b952ae5..86e463c 100644 --- a/defaults/main.yaml +++ b/defaults/main.yaml @@ -80,5 +80,5 @@ cosmostat_server_api: "https://cosmostat.matt-cloud.com/" local_api_address: "http://{{ cosmostat_server_ip }}:{{ custom_api_port }}/" cosmostat_server_reporter: true # setting this to true for default install -disable_local_api: true +disable_local_dashboard: true ... \ No newline at end of file diff --git a/files/api/Components.py b/files/api/Components.py index 511a9a8..fb136a0 100644 --- a/files/api/Components.py +++ b/files/api/Components.py @@ -9,6 +9,7 @@ import json import time import weakref import base64, hashlib +import ipaddress from typing import Dict, Any, List # Import Cosmos Settings from Cosmos_Settings import * @@ -255,6 +256,19 @@ class Component: if value not in empty_value and name not in self.virt_ignore: result.append(this_metric) return result + + # simple data value return + def get_metrics_value(self, type = None): + result = [] + print(f"Metric type: {type}") + for name, value in self._metrics.items(): + print(f"Metric Property: {name}") + if type in name: + result.append(value) + if len(result) == 1: + return result[0] + else: + return result ######################################################## # various data functions @@ -285,7 +299,7 @@ class Component: these_properties.append({"Property": name, "Value": value}) else: for name, value in self._properties.items(): - if name == type: + if type in name: these_properties.append({"Property": name, "Value": value}) result = { "Source": self.name, @@ -294,6 +308,19 @@ class Component: } return result + # simple data value return + def get_property_value(self, type = None): + result = [] + print(f"Component type: {type}") + for name, value in self._properties.items(): + print(f"Component Property: {name}") + if type in name: + result.append(value) + if len(result) == 1: + return result[0] + else: + return result + # full data return def get_description(self): these_properties = [] @@ -360,7 +387,7 @@ class System: self._properties: Dict[str, str] = {} self._metrics: Dict[str, str] = {} self._virt_string = run_command('systemd-detect-virt', zero_only = True, req_check = False) - + self.default_gateway = run_command("ip route show | grep def | awk '{print $3}'", zero_only = True, req_check = False) self._virt_ignore = self.virt_ignore if self._virt_string == "none": self._virt_ignore = [] @@ -374,7 +401,9 @@ class System: self.update_live_keys() # initialze components for component in component_types: - self.create_component(component) + self.create_component(component) + self.primary_ip = self.get_primary_ip() + print(f"Cosmostat System IP: {self.primary_ip}") def __str__(self): components_str = "\n".join(f" - {c}" for c in self.components) @@ -444,6 +473,22 @@ class System: log_data(log_output = f'Creating component {component["name"]}', log_level = "debug_output") new_component = Component(name = component_name, comp_type = component_name, parent_system = self) self.components.append(new_component) + + # return the IP of the interface with the gateway + # remember the IP is stored like this 192.168.1.0/24 + def get_primary_ip(self): + primary_ip = None + interfaces = self.get_components(component_type = "LAN") + for interface in interfaces: + interface_ip = interface.get_metrics_value(type = "IP Address") + interface_subnet = None + if "/" in interface_ip: + interface_subnet = ipaddress.ip_network(interface_ip, strict=False) + print(f"interface IP: {interface_ip} - Default Gateway: {self.default_gateway}") + if is_ip_in_subnets(self.default_gateway ,interface_subnet): + primary_ip = str(interface_ip).split("/")[0] + print(f"Primary IP: {primary_ip}") + return primary_ip ######################################################## # helper class functions @@ -456,7 +501,7 @@ class System: else: result = [] for component in self.components: - if component.type == component_type: + if component_type in component.type: result.append(component) if component.is_multi(): return result @@ -648,4 +693,17 @@ def get_device_list(device_type_name: str): return result +# 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 + + diff --git a/files/api/Cosmos_Settings.py b/files/api/Cosmos_Settings.py index a29aed6..8b99a7b 100644 --- a/files/api/Cosmos_Settings.py +++ b/files/api/Cosmos_Settings.py @@ -19,7 +19,7 @@ app_settings = { "custom_api_port": "5000", "cosmostat_server_api": "http://10.200.27.20:5000/", "REAL_API_KEY": ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(256)), - "disable_local_api": False, + "disable_local_dashboard": False, "local_api_address": "http://10.200.27.20:5000/", "cosmostat_server_ip": "10.200.27.20", "api_bind_ip": "192.168.37.1" diff --git a/files/api/Cosmostat.py b/files/api/Cosmostat.py index 5e5e5bd..fa14b70 100644 --- a/files/api/Cosmostat.py +++ b/files/api/Cosmostat.py @@ -16,6 +16,7 @@ import subprocess import json import time import weakref +import ipaddress import base64, hashlib from typing import Dict, Any, List # Import Cosmos Settings @@ -35,10 +36,11 @@ class CosmostatServer: # instantiate new Cosmostat server ############################################################ - def __init__(self, name: str): + def __init__(self, name: str, hostname: str): # the system needs a name, should be equal to the uuid of the client self.name = name self.short_id = self.short_uuid(self.name) + self.hostname = hostname log_data(log_output = f"Cosmostat Server {self.short_id} initializing", log_level = "log_output") # system contains an array of CosmostatClient Objects self.systems = [] @@ -52,14 +54,18 @@ class CosmostatServer: def add_system(self, system_dictionary: dict): if not self.check_uuid(system_dictionary["uuid"]): + print(f"Adding Cosmostat Host: {system_dictionary['hostname']}") new_cosmostat_clilent = CosmostatClient( name = system_dictionary["short_id"], uuid = system_dictionary["uuid"], hostname = system_dictionary["hostname"], + active_ip = system_dictionary["active_interface"], + is_server = system_dictionary["is_server"], data_timestamp = time.time(), client_properties = system_dictionary["client_properties"], redis_data = {} ) + print(f"New Cosmostat Server Object - IP {system_dictionary['active_interface']}") self.systems.append(new_cosmostat_clilent) log_data(log_output = f'Client system {system_dictionary["short_id"]} added', log_level = "log_output") return new_cosmostat_clilent.data_timestamp @@ -123,6 +129,13 @@ class CosmostatServer: result.append(system.hostname) return result + def get_metrics_from_ip(self, ip): + this_metrics = "" + for system in self.systems: + if system.active_ip == ip: + this_metrics = system.redis_data + return this_metrics + def purge_stale_hostnames(self): now = time.time() fresh_systems = [] @@ -131,6 +144,22 @@ class CosmostatServer: if age <= 60: # keep only fresh servers fresh_systems.append(system) self.systems = fresh_systems # replace the old list + + # return the VPN IP if present, if just_check then it returns true/false if th + def get_vpn_ip(self, remote_ip, just_check = False): + cosmos_vpn_subnet = "10.200.26.0/24" + vpn_ip = None + this_client_metrics = self.get_metrics_from_ip(remote_ip) + for metric in this_client_metrics: + # if the metric is from VPN, is an IP address, and it belongs to the Jenkins VPN subnet + if metric["Metric"] == "IP Address" and "VPN" in metric["Source"] and is_ip_in_subnets(metric["Data"].split("/")[0], cosmos_vpn_subnet): + vpn_ip = metric["Data"].split("/")[0] + if just_check and vpn_ip is not None: + vpn_ip = False + elif just_check and vpn_ip is None: + vpn_ip = True + return vpn_ip + ################################################################# ### Cosmostat Client Class @@ -145,10 +174,12 @@ class CosmostatClient: # instantiate new Cosmostat server ############################################################ - def __init__(self, name: str, uuid: str, hostname: str, data_timestamp: float, client_properties: dict, redis_data: dict): + def __init__(self, name: str, uuid: str, hostname: str, active_ip: str, is_server: str, data_timestamp: float, client_properties: dict, redis_data: dict): self.name = name self.uuid = uuid self.hostname = hostname + self.active_ip = active_ip + self.is_server = is_server self.data_timestamp = data_timestamp self.client_properties = client_properties self.redis_data = redis_data @@ -165,4 +196,17 @@ class CosmostatClient: return self.client.properties def get_redis(self): - return self.redis_data \ No newline at end of file + return self.redis_data + +# 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 + diff --git a/files/api/app.py b/files/api/app.py index 47e5b26..c5f98e2 100644 --- a/files/api/app.py +++ b/files/api/app.py @@ -3,10 +3,10 @@ ### cosmostat service handler ####################################################################### -from flask import Flask, jsonify, request, Response +from flask import Flask, jsonify, request, Response, abort from flask_apscheduler import APScheduler from typing import Dict, Union -import json, time, redis, yaml +import json, time, redis, yaml, datetime import secrets, string import requests from requests import RequestException, Response @@ -63,6 +63,8 @@ def get_server_redis_data(): "data_timestamp": client.data_timestamp, "uuid": client.uuid, "short_id": client.name, + "active_ip": client.active_ip, + "is_server": client.is_server, "redis_data": client.redis_data } result.append(this_client_key) @@ -168,7 +170,7 @@ def get_php_summary(): "info_strings": component.get_properties_strings(return_simple = True) } system_components.append(this_component) - + this_primary_ip = cosmostat_client.primary_ip if run_cosmostat_server(): client_uuid = cosmostat_server.get_uuid_from_hostname(cosmostat_client.name) data_timestamp = cosmostat_server.get_system(client_uuid) @@ -178,11 +180,13 @@ def get_php_summary(): "component_name": "Data Timestamp", "info_strings": f"Data is {data_timestamp} seconds old" } + this_primary_ip = cosmostat_settings["cosmostat_server_ip"] system_components.append(component_age) result = [{ "system_properties": system_properties, - "system_components": system_components + "system_components": system_components, + "active_interface": this_primary_ip }] return result @@ -217,6 +221,7 @@ def create_client(): result = {} # check the request and return payload dict {} if all good payload = client_submit_check(request = request, dict_name = "client_properties") + # if the client does not exist, create it if not cosmostat_server.check_uuid(payload["uuid"]): result = run_create_client(payload) else: @@ -266,6 +271,20 @@ def get_server_redis(): result = {"message": "server not running on this endpoint"} return jsonify(result) +# api to get server redis data +@app.route('/client_ip_summary', methods=['GET']) +def client_ip_summary(): + result = [] + if run_cosmostat_server(): + result = get_client_ip_summary() + else: + result = {"message": "server not running on this endpoint"} + return jsonify(result) + +# return inventory file of all clients for update +@app.route("/client_inventory", methods=["GET"]) +def client_inventory(): + return build_inventory() ####################################################################### ### Server Flask Helpers @@ -295,6 +314,7 @@ def run_update_client(this_client): # create client on server def run_create_client(this_client): if public_api_check(this_client): + #this_client["active_interface"] = cosmostat_client.primary_ip timestamp_update = cosmostat_server.add_system(system_dictionary = this_client) update_status = f'created client {this_client["short_id"]}' return { @@ -331,6 +351,7 @@ def client_submit_check(request, dict_name: str): missing = required_keys - payload.keys() if missing: raise ValueError(f"Missing required keys: {', '.join(sorted(missing))}") + return payload # generate cosmostat server summary @@ -366,14 +387,92 @@ def get_client_details(): result = {"message": "no clients reporting"} return result +# get client IP summary +def get_client_ip_summary(return_list = False): + result = [] + for client in cosmostat_server.systems: + + device_info = { + "small_id": client.name, + "uuid": client.uuid, + "hostname": client.hostname, + "lan_ip": client.active_ip, + } + if client.hostname != cosmostat_server.hostname: + if return_list: + result.append(client.active_ip) + else: + result.append(device_info) + return result + +# build client ansible inventory file +def build_inventory(): + all_ips = get_client_ip_summary(return_list = True) + cosmos_subnets = [ + "172.25.1.0/24", + "172.20.0.0/16", + "172.19.10.0/24", + "10.200.26.0/24", + "10.200.27.0/24", + "192.168.60.0/24", + ] + ips = [] + bad_ips = [] + # build list of reachable IPs + for ip in all_ips: + for subnet in cosmos_subnets: + if is_ip_in_subnets(ip, subnet) and cosmostat_server.get_vpn_ip(ip, just_check = True): + ips.append(ip) + # list of unreachable IPs + for ip in all_ips: + if ip not in ips: + bad_ips.append(ip) + # add any VPN IPs for bad IPs to the main IP list + for ip in bad_ips: + ips.append(cosmostat_server.get_vpn_ip(ip)) + hosts = {ip: {"ansible_host": ip} for ip in ips} + inventory = { + "all": { + "hosts": hosts, + "vars": { + "refresh_only": "true", + "ansible_connection": "ssh", + "ansible_ssh_private_key_file": "/var/jenkins_home/jenkins_key", + "ansible_python_interpreter": "/usr/bin/python3", + "jenkins_user": 'automate', + "jenkins_group": 'Jenkins-Admin', + "subnet_group_check": 'Jenkins-AllSubnets', + "SERVER_SUBNET_GROUP": 'Jenkins-AllSubnets', + "inventory_generation_timestamp": f"{datetime.datetime.now().isoformat()}", + "playbook_file": '/var/jenkins_home/ansible/playbooks/cosmostat.yaml', + "quick_refresh": "true", + "noisy_test": "false", + "debug_output": "false", + "push_redis": "false", + "run_background": "true", + "log_output": "true", + "public_dashboard": "false", + "custom_port": "80", + "custom_api_port": "5000", + "cosmostat_server_reporter": "true", + "cosmostat_server": "false", + "secure_api": "false", + "disable_local_dashboard": "true", + "REAL_API_KEY": f"{cosmostat_settings['REAL_API_KEY']}" + }, + } + } + return yaml.safe_dump( + inventory, + default_flow_style=False, + sort_keys=False, + ) + ####################################################################### ### Cosmostat Client Subroutines ####################################################################### -# since the API isn't running -# def local_client_update(): - -# Cosmostat Client Reporter +# Cosmostat Client Reporter Handler def client_update(): api_url = f"{cosmostat_server_api()}update_client" payload = get_client_payload(get_client_redis_data(human_readable = False), "redis_data") @@ -385,6 +484,7 @@ def client_update(): if not result or not result.get('client_updated'): log_data(log_output = f"Client not updated, initializing", log_level = "log_output") result = client_api_initialize() + # this result does not matter and is not used anywhere return result # Cosmostat Client Initializer @@ -393,6 +493,9 @@ def client_api_initialize(): # generate payload payload = get_client_payload(get_php_summary(), "client_properties") # execute API call + payload["active_interface"] = cosmostat_client.primary_ip + payload["is_server"] = False + result = client_submission_handler(api_url, payload) return result @@ -410,7 +513,7 @@ def client_submission_handler(api_url: str, payload: dict): try: result = response.json() except ValueError as exc: - log_data(log_output = "Server responded with non-JSON payload: {response.text!r}", log_level = "log_output") + log_data(log_output = f"Server responded with non-JSON payload: {response.text!r}", log_level = "log_output") return result def get_client_payload(system_dictionary: dict, dictionary_name: str): @@ -427,7 +530,6 @@ def get_client_payload(system_dictionary: dict, dictionary_name: str): } return payload - ####################################################################### ####################################################################### ### Main Subroutine @@ -452,7 +554,7 @@ if __name__ == '__main__': # instantiate and return the Cosmoserver System object def new_cosmostat_server(): - new_server = CosmostatServer(cosmostat_client.uuid) + new_server = CosmostatServer(name = cosmostat_client.uuid, hostname = jenkins_hostname_settings()) log_data(log_output = f"New Cosmostat serverobject name: {new_server.name}", log_level = "log_output") return new_server @@ -473,7 +575,7 @@ if __name__ == '__main__': cosmostat_client.update_system_state() # publish to redis if the web dashboard is active locally - if app_settings["push_redis"] and not app_settings["disable_local_api"]: + if app_settings["push_redis"] and not app_settings["disable_local_dashboard"]: update_redis_server() # report data to the server if configured @@ -489,7 +591,7 @@ if __name__ == '__main__': run_update_client(get_client_payload(get_client_redis_data(human_readable = False), "redis_data")) log_data(log_output = f"{this_client}", log_level = "noisy_test") - time.sleep(0.5) + time.sleep(0.2) ###################################### # instantiate client @@ -514,15 +616,18 @@ if __name__ == '__main__': log_data(log_output = f"Cosmostat Server Start", log_level = "log_output") cosmostat_server = new_cosmostat_server() this_client = get_client_payload(get_php_summary(), "client_properties") + this_client["active_interface"] = cosmostat_settings["cosmostat_server_ip"] + this_client["is_server"] = True timestamp_update = cosmostat_server.add_system(system_dictionary = this_client) - # moving this here so all the bits exist - client_update() + # if not a server, update client on the server + else: + client_update() ###################################### # send initial stats update to redis ###################################### - if app_settings["push_redis"] and not app_settings["disable_local_api"]: + if app_settings["push_redis"] and not app_settings["disable_local_dashboard"]: log_data(log_output = f"Initial Redis Push", log_level = "log_output") update_redis_server() @@ -530,7 +635,7 @@ if __name__ == '__main__': # Flask scheduler for scanner ###################################### - if app_settings["run_background"] and not app_settings["disable_local_api"]: + if app_settings["run_background"] and not app_settings["disable_local_dashboard"]: log_data(log_output = f"Background Function Initializing", log_level = "log_output") log_data(log_output = "Loading flask background subroutine...", log_level = "log_output") @@ -549,7 +654,7 @@ if __name__ == '__main__': # Flask API ###################################### log_data(log_output = f"gateway: {service_gateway_ip()} - port: {service_api_port()}", log_level = "log_output") - if not app_settings["disable_local_api"]: + if not app_settings["disable_local_dashboard"]: log_data(log_output = f"Main API Start", log_level = "log_output") app.run(debug=False, host=service_gateway_ip(), port=service_api_port()) else: diff --git a/files/api/descriptors.json b/files/api/descriptors.json index 127dcab..a5dcda3 100644 --- a/files/api/descriptors.json +++ b/files/api/descriptors.json @@ -247,5 +247,20 @@ "metrics": { "placeholder": "" } + }, + { + "name": "BAT", + "description": "Battery - {Device Name} - capacity {Capacity}", + "multi_check": "True", + "device_list": "acpi | grep Battery | cut -d: -f1", + "properties": { + "Device Name": "echo {this_device}", + "Capacity": "acpi -V | jc --acpi | jq '.[] | select(.type==\"Battery\") | .design_capacity_mah' " + }, + "metrics": { + "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" } ] \ No newline at end of file diff --git a/files/api/new_descriptors.json b/files/api/new_descriptors.json index 42d5543..b7f3250 100644 --- a/files/api/new_descriptors.json +++ b/files/api/new_descriptors.json @@ -41,6 +41,22 @@ ] }, + { + "name": "BAT", + "description": "Battery - {Device Name} - capacity {Capacity}", + "multi_check": "True", + "device_list": "acpi | grep Battery | cut -d: -f1", + "properties": { + "Device Name": "echo {this_device}", + "Capacity": "acpi -V | jc --acpi | jq '.[] | select(.type==\"Battery\") | .design_capacity_mah' " + }, + "metrics": { + "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" + }, + { "SATA GBW": "sudo /usr/sbin/smartctl -x --json /dev/{this_device} | jq -r '.physical_block_size as $block |.ata_device_statistics.pages[] | select(.name == \"General Statistics\") | .table[] | select(.name == \"Logical Sectors Written\") | .value as $sectors | ($sectors * $block) / 1073741824 ' | awk '{{printf \"%.2f GiB Written\\n\", $0}}' || true", "NVMe GBW": "sudo /usr/sbin/smartctl -x --json /dev/{this_device} | jq -r ' .nvme_smart_health_information_log.data_units_written as $dw | .logical_block_size as $ls | ($dw * $ls) / 1073741824 ' | awk '{{printf \"%.2f GiB Written\\n\", $0}}' || true" diff --git a/files/docker/Dockerfile b/files/docker/Dockerfile index bf1c022..836828f 100644 --- a/files/docker/Dockerfile +++ b/files/docker/Dockerfile @@ -1,91 +1,47 @@ -# ------------------------------------------------------------------ -# 1. Base image -# ------------------------------------------------------------------ -# We use a slim Debian base so we can use apt‑get to pull every -# component in one go. Debian Bookworm contains all the -# packages we need (nodejs 18, redis, nginx, php8‑fpm, etc.). -# ------------------------------------------------------------------ -FROM php:8.0-apache -# ------------------------------------------------------------------ -# 2. Build arguments – handy if you want to change the port numbers -# without touching the Dockerfile -# ------------------------------------------------------------------ -ARG REDIS_PORT=6379 -ARG NODE_PORT=3000 -ARG PHP_PORT=8080 -ARG NGX_PORT=80 +# Base image +FROM php:8.1-apache -ENV REDIS_PORT=${REDIS_PORT} -ENV NODE_PORT=${NODE_PORT} -ENV PHP_PORT=${PHP_PORT} -ENV NGX_PORT=${NGX_PORT} +# Install system packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + # Services + redis-server nginx \ + # Process supervisor + supervisor \ + # Clean up + && rm -rf /var/lib/apt/lists/* -# ------------------------------------------------------------------ -# 3. Install all the system packages we need -# ------------------------------------------------------------------ -RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y \ - curl gnupg ca-certificates \ - nodejs npm \ - redis-server \ - nginx \ - supervisor \ - && apt-get clean && 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/* -# ------------------------------------------------------------------ -# 4. Prepare the working directories -# ------------------------------------------------------------------ -# Node app -WORKDIR /app -COPY web/node_server/package.json ./ -RUN npm install -COPY web/node_server/ ./ - -# Web‑dashboard static files -COPY web/html /var/www/html/ - -# API settings file +# copy the config file COPY cosmostat_settings.yaml /app/cosmostat_settings.yaml -# Nginx config – you can keep the same file you used for the -# proxy service in the compose file. It will proxy 3000 (WS) -# and 8080 (PHP) to the local container. -COPY web/proxy/nginx.conf /etc/nginx/nginx.conf +# Node on 3000 +WORKDIR /usr/src/app +COPY web/node_server/ . +RUN npm install --only=production -# ------------------------------------------------------------------ -# 5. Supervisord configuration -# ------------------------------------------------------------------ -# Create a minimal supervisord.conf that will launch the four -# services from the same container. -RUN mkdir -p /etc/supervisor/conf.d && \ - cat > /etc/supervisor/conf.d/supervisord.conf <//' /etc/apache2/sites-enabled/000-default.conf +COPY web/html/ /var/www/html/ -[program:redis] -command=/usr/bin/redis-server --port ${REDIS_PORT} -autostart=true -autorestart=true -user=root +# nginx on 80 +RUN rm -rf /etc/nginx/sites-enabled/default +COPY web/proxy/nginx.conf /etc/nginx/conf.d/default.conf -[program:node] -command=npm run start -autostart=true -autorestart=true -user=root +# Add supervisord configuration +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf -[program:nginx] -command=/usr/sbin/nginx -g 'daemon off;' -autostart=true -autorestart=true -user=root -EOF +# Expose ports +EXPOSE 80 +EXPOSE 6379 -# ------------------------------------------------------------------ -# 6. Expose the ports -# ------------------------------------------------------------------ -EXPOSE ${REDIS_PORT} ${NGX_PORT} +# healthcheck looks for apache +HEALTHCHECK CMD netstat -ltn | grep -c ":8080" > /dev/null; if [ 0 != $? ]; then exit 1; fi; -# 7. Default command – start supervisord -CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] \ No newline at end of file +# Start supervisord +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] \ No newline at end of file diff --git a/files/docker/Dockerfile-1 b/files/docker/Dockerfile-1 deleted file mode 100644 index b7b06f7..0000000 --- a/files/docker/Dockerfile-1 +++ /dev/null @@ -1,48 +0,0 @@ -FROM php:8.0-apache - -RUN set -eux; \ - apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - gnupg \ - lsb-release \ - wget \ - curl \ - sudo \ - redis-server \ - nginx \ - supervisor \ - && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -# Node application -COPY web/node_server/ . -RUN npm install --only=production - -#RUN npm ci --production - -# PHP static files (public web root) -COPY web/html/ /var/www/html/ - -# NGINX config (overwrites the default) -COPY web/proxy/nginx.conf /etc/nginx/conf.d/default.conf - -# Shared settings file (read‑only) -COPY cosmostat_settings.yaml /app/cosmostat_settings.yaml - -# Supervisor configuration -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf - -# Entrypoint script -COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh - -EXPOSE 6379 -EXPOSE 80 - -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] - - diff --git a/files/docker/entrypoint.sh b/files/docker/entrypoint.sh deleted file mode 100644 index db95be6..0000000 --- a/files/docker/entrypoint.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -# Ensure the shared config file is readable -chmod 644 /app/cosmostat_settings.yaml - -# Let Supervisor do the heavy lifting -exec "$@" \ No newline at end of file diff --git a/files/docker/supervisord.conf b/files/docker/supervisord.conf index 3fdd918..5ba104a 100644 --- a/files/docker/supervisord.conf +++ b/files/docker/supervisord.conf @@ -1,22 +1,46 @@ [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 -stdout_logfile=/dev/stdout -stderr_logfile=/dev/stderr - -[program:node] -command=sh -c "cd /app && node server.js" -stdout_logfile=/dev/stdout -stderr_logfile=/dev/stderr - -[program:apache] -command=/usr/sbin/httpd -DFOREGROUND +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=nginx -g "daemon off;" +command=/usr/sbin/nginx -g 'daemon off;' stdout_logfile=/dev/stdout -stderr_logfile=/dev/stderr \ No newline at end of file +stderr_logfile=/dev/stderr +autorestart=true +priority=3 + +# ------------------------------------------------------------------ +# 4. Node.js +# ------------------------------------------------------------------ +# NOTE: Adjust the command/path to match your app +[program:node] +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/docker/web/html/index.php b/files/docker/web/html/index.php new file mode 100644 index 0000000..300f59b --- /dev/null +++ b/files/docker/web/html/index.php @@ -0,0 +1,143 @@ + + + + + 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 +

+ +
+
+ + + + "; + $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 new file mode 100644 index 0000000..dcfcdf0 --- /dev/null +++ b/files/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/docker/web/html/src/styles.css b/files/docker/web/html/src/styles.css new file mode 100644 index 0000000..2084b8c --- /dev/null +++ b/files/docker/web/html/src/styles.css @@ -0,0 +1,246 @@ +/* ------------------------------------------------- + 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; min-height: 100vh; } + +.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; +} + diff --git a/files/docker/web/node_server/package.json b/files/docker/web/node_server/package.json new file mode 100644 index 0000000..3d33e2b --- /dev/null +++ b/files/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/docker/web/node_server/server.js b/files/docker/web/node_server/server.js new file mode 100644 index 0000000..b2e5e17 --- /dev/null +++ b/files/docker/web/node_server/server.js @@ -0,0 +1,132 @@ +// 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 file = fs.readFileSync(path.resolve(__dirname, 'cosmostat_settings.yaml'), '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); + + // 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 (patched) --------------------------------- */ +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)); + +(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); + 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/docker/web/proxy/nginx.conf b/files/docker/web/proxy/nginx.conf new file mode 100644 index 0000000..50e43c3 --- /dev/null +++ b/files/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://192.168.37.1: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://192.168.37.1: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/files/server/system_metrics.js b/files/server/system_metrics.js index 5945cfa..e72847e 100644 --- a/files/server/system_metrics.js +++ b/files/server/system_metrics.js @@ -62,29 +62,74 @@ /* ========================================================== 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 = systemList.map(s => s.short_id); - if (arraysEqual(current, newIds)) return; + 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 list - systemList.forEach(item => { + ul.innerHTML = ''; // reset + + sorted.forEach(item => { const li = document.createElement('li'); - // status dot - keyed by short_id + + // • 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 + + // • 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 ========================================================== */ diff --git a/tasks/api.yaml b/tasks/api.yaml index f27d728..a0a6dc3 100644 --- a/tasks/api.yaml +++ b/tasks/api.yaml @@ -14,7 +14,7 @@ scope: user # create service working folder -- name: Cosmostat - API - create cosmos user service folder +- name: Cosmostat - API - create cosmos user systemd folder file: path: "{{ user_service_folder }}" state: directory diff --git a/tasks/docker.yaml b/tasks/docker.yaml new file mode 100644 index 0000000..46d180b --- /dev/null +++ b/tasks/docker.yaml @@ -0,0 +1,79 @@ +--- +############################################### +# This part sets up cosmostat web dashboard +############################################### + +- name: Cosmostat - Web - stop containers + community.docker.docker_compose_v2: + project_src: "{{ service_control_docker_folder }}" + state: stopped + ignore_errors: yes + +# Create web Folder +- name: "Cosmostat - Web - create {{ service_control_docker_folder }}" + file: + path: "{{ service_control_docker_folder }}" + state: directory + mode: '0755' + owner: "{{ service_user }}" + group: "{{ service_user }}" + +- name: Cosmostat - Web - copy web files + copy: + src: "docker/" + dest: "{{ service_control_docker_folder }}/" + mode: 0755 + owner: "{{ service_user }}" + group: "{{ service_user }}" + +- name: Cosmostat - Web - template docker-compose.yaml + template: + src: docker-compose.yaml + dest: "{{ service_control_docker_folder }}/docker-compose.yaml" + mode: 0644 + +- name: "Cosmostat - Web - template cosmostat_settings.yaml" + 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 + community.docker.docker_compose_v2: + project_src: "{{ service_control_docker_folder }}" + state: present + register: docker_output +- debug: | + msg="{{ docker_output.actions }}" + + +... \ No newline at end of file diff --git a/tasks/init.yaml b/tasks/init.yaml index df36bed..1af1893 100644 --- a/tasks/init.yaml +++ b/tasks/init.yaml @@ -61,13 +61,6 @@ shell: "loginctl enable-linger {{ service_user }}" register: user_linger -# - name: Reboot target after linger change -# reboot: -# msg: "Cosmostat - Init - Rebooting target for linger enable" -# pre_reboot_delay: 10 -# reboot_timeout: 600 -# when: user_linger.changed - # create service working folder - name: Cosmostat - Init - create cosmostat service folder file: diff --git a/tasks/main.yaml b/tasks/main.yaml index 2efc5a9..e794eda 100644 --- a/tasks/main.yaml +++ b/tasks/main.yaml @@ -13,12 +13,11 @@ # set up API - name: Build API -# when: false include_tasks: api.yaml # set up web stack - name: Build Web Dashboard - when: not disable_local_api + when: not disable_local_dashboard | bool include_tasks: web.yaml ... \ No newline at end of file diff --git a/tasks/server.yaml b/tasks/server.yaml index 126ce31..f888607 100644 --- a/tasks/server.yaml +++ b/tasks/server.yaml @@ -9,6 +9,11 @@ owner: "{{ service_user }}" group: "{{ service_user }}" +- name: Cosmostat - Server Dashboard - delete redis.js + ansible.builtin.file: + path: "{{ service_control_web_folder }}/html/src/redis.js" + state: absent + - name: Cosmostat - Server Dashboard - copy system_metrics.js copy: src: server/system_metrics.js @@ -16,10 +21,5 @@ mode: 0755 owner: "{{ service_user }}" group: "{{ service_user }}" - -- name: Cosmostat - Server Dashboard - delete redis.js - ansible.builtin.file: - path: "{{ service_control_web_folder }}/html/src/redis.js" - state: absent - + ... \ No newline at end of file diff --git a/tasks/web.yaml b/tasks/web.yaml index 05f7002..38b4f0f 100644 --- a/tasks/web.yaml +++ b/tasks/web.yaml @@ -4,8 +4,9 @@ ############################################### - name: Cosmostat - Web - stop containers + when: not quick_refresh | bool community.docker.docker_compose_v2: - project_src: "{{ service_control_docker_folder }}" + project_src: "{{ service_control_web_folder }}" state: stopped ignore_errors: yes @@ -18,14 +19,6 @@ owner: "{{ service_user }}" group: "{{ service_user }}" -#- name: Cosmostat - Web - copy docker files -# copy: -# src: "docker/" -# dest: "{{ service_control_docker_folder }}" -# mode: 0755 -# owner: "{{ service_user }}" -# group: "{{ service_user }}" - - name: Cosmostat - Web - copy web files copy: src: "web/" @@ -55,11 +48,15 @@ include_tasks: server.yaml - name: Cosmostat - Web - Start containers + when: not quick_refresh | bool community.docker.docker_compose_v2: project_src: "{{ service_control_web_folder }}" state: present register: docker_output -- debug: | + +- name: Cosmostat - Web - Show docker status + when: not quick_refresh | bool + debug: | msg="{{ docker_output.actions }}" diff --git a/templates/cosmostat_settings.yaml b/templates/cosmostat_settings.yaml index 61d095e..c8f917f 100644 --- a/templates/cosmostat_settings.yaml +++ b/templates/cosmostat_settings.yaml @@ -34,7 +34,8 @@ custom_api_port: {{ custom_api_port }} cosmostat_server: {{ cosmostat_server }} cosmostat_server_api: "{{ cosmostat_server_api }}" cosmostat_server_reporter: {{ cosmostat_server_reporter }} -disable_local_api: {{ disable_local_api }} +disable_local_dashboard: {{ disable_local_dashboard }} REAL_API_KEY: "{{ REAL_API_KEY }}" cosmostat_server_ip: "{{ cosmostat_server_ip }}" + ... \ No newline at end of file diff --git a/templates/docker-compose.yaml b/templates/docker-compose.yaml index f387124..192eecb 100644 --- a/templates/docker-compose.yaml +++ b/templates/docker-compose.yaml @@ -1,66 +1,26 @@ -# for now there is no php code -# to save resources, also disabling nginx -# will map 3000 to 80 here unless this changes +--- services: - redis: - container_name: redis - image: redis:7-alpine - ports: - - {{ docker_gateway }}:6379:6379 + cosmostat-dash: + container_name: cosmostat-dash + image: cosmostat-dash:latest + restart: always + build: + context: . + dockerfile: Dockerfile networks: - cosmostat_net - restart: always - - ws_node: - - image: node:18-alpine - working_dir: /app - command: sh -c "npm install && node server.js" - container_name: ws_node + ports: + - "{{ docker_gateway }}:6379:6379" + - "{{ (docker_gateway + ':') if not public_dashboard | bool else '' }}{{ custom_port }}:80" volumes: - - {{ service_control_web_folder }}/html:/usr/src/app/public - - {{ service_control_web_folder }}/node_server:/app - - /app/node_modules - ports: - # put back to 3000 if the stack is needed - - {{ (docker_gateway + ':') if secure_api else '' }}80:3000 - networks: - - cosmostat_net - restart: always - depends_on: - - redis - -# these will be disabled until a stack is needed -# web_dash: -# container_name: web_dash -# image: php:8.0-apache -# ports: -# - {{ (docker_gateway + ':') if secure_api else '' }}8080:80 -# volumes: -# - ./html:/var/www/html/ -# networks: -# - cosmostat_net -# restart: always -# -# nginx_proxy: -# container_name: nginx_proxy -# image: nginx:latest -# ports: -# - "{{ (docker_gateway + ':') if secure_api else '' }}80:80" -# volumes: -# - ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf -# networks: -# - cosmostat_net -# restart: always -# depends_on: -# - web_dash -# - ws_nodenetworks: - +# - "/opt/cosmostat/docker/web/html:/var/www/html" +# - "/opt/cosmostat/docker/web/node_server:/app" + - "/opt/cosmostat/api/cosmostat_settings.yaml:/app/cosmostat_settings.yaml:ro" networks: cosmostat_net: external: true - +... \ No newline at end of file